19 Commits

Author SHA1 Message Date
fcaaf29307 Release v3.0.0
- Bump version to 3.0.0 and update docs

- Fix Debian payload to include TUI and install /usr/bin/sensorpajen-tui wrapper

- Make systemd unit upgrades safer and ignore deb build artifacts
2025-12-29 15:34:03 +01:00
54d55cf0f6 Switching editors 2025-12-29 12:22:44 +01:00
cfa24d1fa5 feat: implement Textual TUI and SQLite database for sensor management 2025-12-29 09:39:33 +01:00
4213b6101a Update debian/changelog for v2.0.0 production release 2025-12-28 11:09:16 +01:00
e9b8d56f6d Release v2.0.0 - Production ready
Version bump:
- Update VERSION to 2.0.0 (from 2.0.0-dev)
- Update pyproject.toml to 2.0.0
- Change development status to Production/Stable

Documentation updates:
- Add Debian package installation instructions (system-wide)
- Add sensor discovery and approval workflow documentation
- Update configuration section with approval workflow
- Update service management for system installation
- Update troubleshooting for system installation
- Update MQTT settings documentation
- Add links to download Debian packages from releases

The application is now production-ready with:
- Systemd integration for automatic service management
- Automatic startup and restart on failure
- Configuration via /etc/sensorpajen/
- Runtime state in /var/lib/sensorpajen/
- Interactive sensor approval workflow
- Automatic configuration reload
- Comprehensive logging via journalctl
2025-12-28 10:54:57 +01:00
a55d065c38 Add bytecode cleanup to postinst before building wheel
Remove stale .pyc files and __pycache__ directories before installing
the sensorpajen package to ensure Python rebuilds bytecode fresh.

This should prevent cached bytecode issues from old syntax errors.
2025-12-28 10:42:16 +01:00
eee68e4034 Fix syntax error in approve_sensors.py
Missing except clause that got accidentally removed during previous edit.
2025-12-28 10:34:28 +01:00
c3dc5677b9 Improve approve-sensors feedback for marking sensors
Changes:
- approve_sensor: Add explicit logging that sensor was marked as approved
- ignore_sensor: Add explicit logging that sensor was marked in discovered_sensors.json
- Both now clearly indicate the sensor status is updated, not just added/ignored

This makes it clear to users that approved sensors will no longer appear as
pending in future runs, since their status in discovered_sensors.json is
changed from 'pending' to 'approved' or 'ignored'.
2025-12-28 10:32:08 +01:00
fc0399a454 Fix system installation state directory
The service was failing with 'Read-only file system' when trying to create
discovered_sensors.json in the /etc/sensorpajen config directory.

Changes:
- config.py: Add STATE_DIR for runtime state
  - System mode: /var/lib/sensorpajen (writable at runtime)
  - Dev mode: config/ (same as config directory)
- config.py: Use STATE_DIR for discovered_sensors.json path
- debian/postinst: Create and own /var/lib/sensorpajen
- debian/sensorpajen.service: Add /var/lib/sensorpajen to ReadWritePaths
- debian/postinst: Remove discovered_sensors.json.example copy (created at runtime)

This separates:
- Config: /etc/sensorpajen (static, not updated by service)
- State: /var/lib/sensorpajen (dynamic, updated by service at runtime)
2025-12-28 09:33:26 +01:00
85af215d73 Fix postinst: Install sensorpajen package in venv
The venv had dependencies installed but not the sensorpajen package itself,
causing 'No module named sensorpajen' errors when running.

Changes:
- After installing dependencies from requirements.txt
- Now also runs 'pip install --no-deps .' to install sensorpajen
- Uses --no-deps to avoid re-installing already-installed dependencies
- Installed in /opt/sensorpajen where pyproject.toml exists

Fixes: ModuleNotFoundError: No module named 'sensorpajen'
2025-12-28 09:29:40 +01:00
c5e6187523 Allow starting without configured sensors for discovery-only mode
- config.SensorConfig.load() now warns instead of raising FileNotFoundError
  if sensors.json doesn't exist
- main.py no longer exits if len(sensors) == 0
- Instead, warns user and suggests using 'sensorpajen approve-sensors'
- Application will now start in discovery-only mode
- This allows users to use the discovery workflow to add sensors

Changes:
- config.py: Handle missing sensors.json gracefully
- main.py: Log warning instead of error when no sensors configured
  and continue running (allows discovery to work)

Fixes: Unable to start application for initial sensor discovery
2025-12-28 09:20:33 +01:00
4000d0972e Final polish for deploy 2025-12-28 00:42:05 +01:00
e1c842b719 Improve sensorpajen.env.example documentation
- Add comments explaining absolute vs relative paths
- Clarify usage for system vs development installations
- Make it clear that SENSOR_CONFIG_FILE/DISCOVERED_SENSORS_FILE
  should use /etc/sensorpajen paths in system installations
- Remove misleading comment about 'relative to project root'
2025-12-28 00:39:00 +01:00
f2ac55eac1 Fix missing PyBluez dependency
The code imports 'bluetooth._bluetooth' which requires the PyBluez package
(not bluepy). PyBluez provides Classic Bluetooth support needed by utils.py
and sensor_reader.py.

- Add pybluez>=0.31 to both requirements.txt and pyproject.toml
- Keep bluepy and paho-mqtt as they are also needed
- bluez system package also needed (already in debian/control)

Fixes: ModuleNotFoundError: No module named 'bluetooth'
2025-12-28 00:33:35 +01:00
3e759d30ed Fix postinst script: set ownership before setcap
- Move chown -R before setcap to preserve Bluetooth capabilities
- setcap must be applied after ownership is set, not before
- Ensures Python executable has proper Bluetooth permissions for venv
2025-12-28 00:29:18 +01:00
aeef9a424c Fix Python dependencies installation in postinst
- Add requirements.txt to debian/install
- Update postinst to use requirements.txt for pip install
- Install from requirements.txt instead of -e . (editable install)
- Ensures bluepy and paho-mqtt are installed in venv
- Fixes 'ModuleNotFoundError: No module named bluetooth' on startup
2025-12-28 00:25:50 +01:00
36e91c7246 Add gzip compression override to debian/rules
- Add override_dh_builddeb with -Zgzip flag
- Ensures package uses gzip instead of zstd compression
- Provides better compatibility with older dpkg versions
- Package now installs successfully on all systems
2025-12-28 00:13:29 +01:00
234391a881 Fix Debian package build issues
- Remove debian/compat file (conflicts with Build-Depends)
- Fix debian/install to use correct readme.md filename
- Update verify-deb.sh to mark debian/compat as optional
- Add -Zgzip flag to dpkg-buildpackage for compatibility
  (uses gzip instead of zstd for better compatibility)
- Update verify-deb.sh to check optional vs required files

Package now builds and installs successfully on systems
without zstd support.
2025-12-28 00:02:49 +01:00
427df1f034 Phase 8: Implement Debian package creation (2025-12-27)
- Create debian/ directory structure with all required files:
  - control: Package metadata and dependencies
  - compat: Debhelper compatibility level
  - changelog: Version history
  - rules: Build instructions
  - install: File installation mappings
  - postinst: Post-installation setup (user, venv, setcap)
  - prerm: Pre-removal script (stop service)
  - postrm: Post-removal script (cleanup, preserve config)
  - sensorpajen.service: System-wide systemd unit

- Update config.py to support dual-mode operation:
  - Auto-detects system installation (/opt/sensorpajen)
  - Uses /etc/sensorpajen for config in system mode
  - Falls back to PROJECT_ROOT/config for development

- Update scripts/approve-sensors.sh for system paths:
  - Detects system vs development installation
  - Uses correct venv and config paths

- Create scripts/verify-deb.sh: Automated build and verification

- Create debian/README.md: Comprehensive packaging documentation

Package features:
- System-wide installation to /opt/sensorpajen/
- Configuration in /etc/sensorpajen/ (preserved on upgrade/remove)
- Dedicated sensorpajen system user
- Automatic venv creation with dependencies
- Bluetooth capabilities set automatically
- Service auto-enabled but waits for config before starting
- Dual-mode code supports both system and development installations
2025-12-27 23:51:39 +01:00
44 changed files with 4894 additions and 1153 deletions

26
.gitignore vendored
View File

@@ -1,3 +1,25 @@
.*
__pycache__
temp
__pycache__/
temp/
*.db
*.egg-info/
.venv/
build/
dist/
# Local configuration (do not commit secrets or device-specific state)
config/sensorpajen.env
config/sensors.json
config/discovered_sensors.json
# Packaging build artifacts
debian/.debhelper/
debian/*.debhelper.log
debian/*.log
debian/*.substvars
debian/debhelper-build-stamp
debian/files
debian/sensorpajen/
# Local experiments
test-local-tui/

View File

@@ -194,6 +194,18 @@ Any agent making changes must:
* Explicit over implicit
* Fewer moving parts
* Easy to debug on a headless device
* **Test-Driven Development (TDD)**: Always write tests before or alongside new features. Ensure the test suite passes before considering a task complete.
---
## Development Workflow
1. **Branching**: All new features and significant changes must be developed in a dedicated feature branch (e.g., `feature/tui-management`).
2. **Task Management**:
- Use `Tasks.md` to track active and future work.
- When a task is finished, **ask the user for confirmation** before moving it.
- Once confirmed, move the task details to `COMPLETED_TASKS.md`.
3. **Roadmap**: Keep `ROADMAP.md` updated as the source of truth for project phases.
---

846
COMPLETED_TASKS.md Normal file
View File

@@ -0,0 +1,846 @@
# Tasks
## Task: Text UI for sensor management (Phase 1)
**Status**: DONE (2025-12-29)
**Priority**: High
**Estimated Effort**: 8-10 hours
**Actual Effort**: ~6 hours
### Implementation Summary
Successfully implemented a modern, full-screen Textual TUI for managing Bluetooth sensors and migrated discovery data to a SQLite database for better persistence and metadata tracking.
### Key Features Implemented
**SQLite Database Migration**:
- Replaced `discovered_sensors.json` with `discovered_sensors.db`.
- Implemented `DatabaseManager` for robust data handling.
- Added tracking for RSSI, appearance count, and last seen timestamps.
- Created migration script for existing JSON data.
**Textual TUI Application**:
- **Discovery View**: Real-time list of pending sensors with "Approve" and "Ignore" actions.
- **Configured View**: Management of `sensors.json` with "Edit" (rename) and "Remove" actions.
- **Ignored View**: List of ignored sensors with "Unignore" capability.
- **Interactive Modals**: User-friendly dialogs for entering sensor names and ignore reasons.
- **Responsive Design**: Full-screen layout with Header, Footer, and Tabbed navigation.
**Integration & Modernization**:
- Added `sensorpajen-tui` entry point for easy access.
- Updated `README.md` with TUI usage instructions and keybindings.
- Followed TDD approach with unit tests for database and TUI initialization.
- Developed in a dedicated `feature/tui-management` branch.
### Files Created/Modified
- `src/sensorpajen/db.py`: SQLite database abstraction layer.
- `src/sensorpajen/discovery_manager.py`: Refactored to use SQLite.
- `src/sensorpajen/tui/app.py`: Main Textual TUI application.
- `src/sensorpajen/tui/modals.py`: Modal dialogs for user input.
- `src/sensorpajen/migrate_to_db.py`: Migration utility.
- `tests/test_db.py`: Unit tests for database logic.
- `tests/test_tui.py`: Unit tests for TUI initialization.
- `pyproject.toml`: Added `textual` dependency and `sensorpajen-tui` script.
### Usage
```bash
# Launch the TUI
sensorpajen-tui
```
**Keybindings:**
- `a`: Approve selected sensor
- `i`: Ignore selected sensor
- `e`: Edit sensor name
- `u`: Unignore sensor
- `Delete`: Remove sensor from monitoring
- `r`: Refresh data
- `q`: Quit
---
## Task: Debian Package Creation
**Status**: DONE (2025-12-27)
**Priority**: Medium
**Estimated Effort**: 4-6 hours
**Actual Effort**: ~5 hours
### Implementation Summary
Successfully created a complete Debian package infrastructure for system-wide installation on Raspberry Pi and Debian-based systems. The package provides:
- **System-wide installation** to `/opt/sensorpajen/` with dedicated user
- **Configuration management** via `/etc/sensorpajen/` (preserved on upgrades)
- **Automatic setup** including Python venv, dependencies, and Bluetooth capabilities
- **Dual-mode operation** supporting both system and development installations
- **Build verification** with automated script
### Files Created
#### Debian Package Files (debian/)
- `control` - Package metadata, dependencies, maintainer info
- `compat` - Debhelper compatibility (v13)
- `changelog` - Version history and release notes
- `rules` - Build instructions (Makefile)
- `install` - File installation mappings
- `postinst` - Post-installation script (creates user, venv, sets capabilities)
- `prerm` - Pre-removal script (stops service)
- `postrm` - Post-removal script (cleanup, preserves config)
- `sensorpajen.service` - System-wide systemd unit file
#### Updated Code
- `src/sensorpajen/config.py` - Auto-detects system vs development installation
- `scripts/approve-sensors.sh` - Supports both installation modes
- `scripts/verify-deb.sh` - Automated build and verification script (NEW)
### Installation Paths
**System Installation (via .deb):**
- Application: `/opt/sensorpajen/`
- Python venv: `/opt/sensorpajen/venv/`
- Configuration: `/etc/sensorpajen/`
- Service: `/etc/systemd/system/sensorpajen.service`
- Examples: `/usr/share/doc/sensorpajen/examples/`
- User: `sensorpajen` (system account, no login)
**Development Installation:**
- Application: `<project-root>/`
- Python venv: `<project-root>/.venv/`
- Configuration: `<project-root>/config/`
- Service: `~/.config/systemd/user/sensorpajen.service`
### Key Features Implemented
✅ System-wide installation with dedicated user
✅ Python venv created automatically in postinst
✅ All dependencies installed from PyPI
✅ Bluetooth capabilities set automatically (setcap)
✅ Systemd service enabled but not started (waits for config)
✅ Configuration preserved on upgrade/remove/purge
✅ Example configs copied to /etc/sensorpajen on first install
✅ Dual-mode code (auto-detects system vs dev)
✅ Automated verification script
✅ Full lintian compliance
### Build and Install
```bash
# Verify and build
./scripts/verify-deb.sh
# Or manually
dpkg-buildpackage -us -uc -b
lintian ../sensorpajen_*.deb
# Install on Raspberry Pi
scp ../sensorpajen_*.deb pi@raspberrypi:~/
ssh pi@raspberrypi
sudo apt install ./sensorpajen_*.deb
# Configure
sudo nano /etc/sensorpajen/sensorpajen.env
sudo nano /etc/sensorpajen/sensors.json
# Start service
sudo systemctl start sensorpajen
sudo journalctl -u sensorpajen -f
```
### Testing Results
✅ Package builds successfully with `dpkg-buildpackage`
✅ Lintian passes without errors (warnings acceptable)
✅ Files installed to correct locations
✅ System user created automatically
✅ Python venv created with all dependencies
✅ Bluetooth capabilities set correctly
✅ Service enabled but not started before config
✅ Configuration preserved on upgrade/remove/purge
✅ Service runs as sensorpajen user (not root)
✅ Logs appear in `journalctl -u sensorpajen`
✅ Dual-mode operation works correctly
### Overview
Create a Debian `.deb` package for system-wide installation of sensorpajen on Raspberry Pi OS and other Debian-based systems. This enables easy distribution and installation via `apt`/`dpkg` instead of manual git clone + pip install.
### Functional Requirements
1. **System-Wide Installation**
- Install application to `/opt/sensorpajen/`
- Create Python virtual environment in `/opt/sensorpajen/venv/`
- Install systemd service file to `/etc/systemd/system/`
- Place configuration in `/etc/sensorpajen/`
- Put example configs in `/usr/share/doc/sensorpajen/examples/`
2. **Dedicated Service User**
- Create `sensorpajen` system user if not exists
- Service runs as `sensorpajen:sensorpajen`
- User has no login shell, no home directory (system account)
3. **Automatic Service Configuration**
- Auto-enable systemd service on installation
- Configure Bluetooth capabilities (setcap) automatically
- Service starts after installation if config exists
4. **Configuration Management**
- Install example configs to `/usr/share/doc/sensorpajen/examples/`:
- `sensorpajen.env.example`
- `sensors.json.example`
- `discovered_sensors.json.example`
- Actual config expected in `/etc/sensorpajen/`:
- `sensorpajen.env`
- `sensors.json`
- Do NOT overwrite existing config on upgrade
- Preserve config on package removal
- Keep config even on purge (user explicitly chooses)
- Postinst should copy the examples into `/etc/sensorpajen/` only if they are missing, leaving any existing config untouched
- Upgrades should refresh `/usr/share/doc/sensorpajen/examples/` with new defaults but never alter live configs under `/etc/sensorpajen/`
5. **Dependency Management**
- Depend on system packages: `python3`, `python3-venv`, `python3-pip`, `bluetooth`, `bluez`
- Create venv and install Python deps from PyPI in postinst script
- Use `pyproject.toml` for Python dependency specification
6. **Package Metadata**
- Package name: `sensorpajen`
- Section: `misc`
- Priority: `optional`
- Architecture: `all`
- Maintainer: Fredrik (fredrik@wahlberg.se)
- Homepage: Repository URL
- Description: "Raspberry Pi Bluetooth temperature sensor monitor"
- Depends: System packages
- Recommends: `mosquitto-clients` (optional)
- **Version Source**: Extract version from `pyproject.toml` during build process.
7. **Files to Include**
- All Python source code from `src/sensorpajen/`
- Scripts from `scripts/` (approve-sensors.sh)
- Systemd service file (system service, not user service)
- Example configuration files
- Documentation: `README.md`, `INSTALL.md`
- License file
### Acceptance Criteria
- [ ] Package builds successfully with `dpkg-buildpackage -us -uc -b`
- [ ] Can install on fresh Raspberry Pi OS with `sudo apt install ./sensorpajen_*.deb`
- [ ] Service user `sensorpajen` created automatically
- [ ] Python venv created in `/opt/sensorpajen/venv/` with all dependencies
- [ ] Bluetooth capabilities set on Python executable
- [ ] Systemd service enabled but not started (waits for config)
- [ ] After copying examples to `/etc/sensorpajen/` and editing, service starts successfully
- [ ] Service runs as `sensorpajen` user, not root
- [ ] Logs appear in `journalctl -u sensorpajen`
- [ ] Package upgrade preserves `/etc/sensorpajen/` config files
- [ ] Package removal (`dpkg -r`) stops service but keeps config
- [ ] Package purge (`dpkg -P`) keeps config (user explicitly deletes if wanted)
- [ ] `lintian` passes with no errors (warnings acceptable)
- [ ] Automated verification script exists that builds the `.deb` and runs `lintian`
### Implementation Details
#### 1. Create `debian/` Directory Structure
```
debian/
├── control # Package metadata and dependencies
├── rules # Build instructions (Makefile)
├── install # Files to install and destinations
├── postinst # Post-installation script
├── prerm # Pre-removal script
├── postrm # Post-removal script
├── changelog # Required for native build (minimal entry)
└── sensorpajen.service # Systemd service file (system-wide)
```
#### 2. `debian/control` File
```
Source: sensorpajen
Section: misc
Priority: optional
Maintainer: Fredrik <fredrik@wahlberg.se>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.5.0
Homepage: https://git.example.com/fredrik/sensorpajen
Package: sensorpajen
Architecture: all
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, ${misc:Depends}
Recommends: mosquitto-clients
Description: Raspberry Pi Bluetooth temperature sensor monitor
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
and publishes readings to MQTT broker. Supports ATC firmware with automatic
sensor discovery and approval workflow.
```
#### 3. `debian/install` File
```
src/sensorpajen/* opt/sensorpajen/src/sensorpajen/
scripts/approve-sensors.sh opt/sensorpajen/scripts/
pyproject.toml opt/sensorpajen/
README.md usr/share/doc/sensorpajen/
INSTALL.md usr/share/doc/sensorpajen/
config/*.example usr/share/doc/sensorpajen/examples/
```
#### 4. `debian/rules` File
```makefile
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_build:
# No build step needed for pure Python
override_dh_auto_install:
# Installation handled by debian/install file
override_dh_auto_clean:
# Clean build artifacts
rm -rf build/ dist/ *.egg-info
```
#### 5. `debian/postinst` Script
```bash
#!/bin/bash
set -e
# Create sensorpajen system user
if ! getent passwd sensorpajen > /dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
fi
# Create config directory
mkdir -p /etc/sensorpajen
chown sensorpajen:sensorpajen /etc/sensorpajen
# Create virtual environment
cd /opt/sensorpajen
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install .
# Set Bluetooth capabilities
PYTHON_PATH=$(readlink -f venv/bin/python3)
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || echo "Warning: setcap failed, install libcap2-bin and rerun"
# Install systemd service
cp debian/sensorpajen.service /etc/systemd/system/
systemctl daemon-reload
# Enable service (but don't start - needs config first)
systemctl enable sensorpajen.service || echo "Warning: systemctl enable failed, enable manually"
# Check if config exists, if so restart service
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
systemctl restart sensorpajen.service
echo "sensorpajen service started"
else
echo "Configuration needed: Copy examples from /usr/share/doc/sensorpajen/examples/ to /etc/sensorpajen/"
echo "Then run: sudo systemctl start sensorpajen"
fi
# Copy example configs if they're missing (never overwrite live config)
for sample in sensorpajen.env.example sensors.json.example discovered_sensors.json.example; do
target="/etc/sensorpajen/${sample%.example}"
if [ ! -f "$target" ]; then
cp "/usr/share/doc/sensorpajen/examples/$sample" "$target"
chown sensorpajen:sensorpajen "$target"
echo "Copied $sample to /etc/sensorpajen/"
fi
done
exit 0
```
#### 6. `debian/prerm` Script
```bash
#!/bin/bash
set -e
# Stop service before removal
if systemctl is-active --quiet sensorpajen.service; then
systemctl stop sensorpajen.service
fi
# Disable service
systemctl disable sensorpajen.service || true
exit 0
```
#### 7. `debian/postrm` Script
```bash
#!/bin/bash
set -e
case "$1" in
remove)
# Service removed but config preserved
echo "sensorpajen removed, config preserved in /etc/sensorpajen/"
;;
purge)
# Even on purge, keep config (user choice to delete manually)
echo "Config preserved in /etc/sensorpajen/ - delete manually if needed"
# Could optionally remove user here, but safer to keep
;;
esac
# Clean up systemd
systemctl daemon-reload || true
exit 0
```
#### 8. `debian/sensorpajen.service` File
```ini
[Unit]
Description=Sensorpajen Bluetooth Temperature Monitor
Documentation=https://github.com/fredrik/sensorpajen
After=bluetooth.target network.target
Wants=bluetooth.target
[Service]
Type=simple
User=sensorpajen
Group=sensorpajen
WorkingDirectory=/opt/sensorpajen
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
Restart=always
RestartSec=10
# Bluetooth capabilities require this to be false
NoNewPrivileges=false
# Hardening (where possible with BT requirements)
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/etc/sensorpajen
[Install]
WantedBy=multi-user.target
```
#### 9. Build Process
```bash
# From repository root
dpkg-deb --build debian sensorpajen_2.0.0_armhf.deb
# Check package contents
dpkg-deb -c sensorpajen_2.0.0_armhf.deb
# Check for issues
lintian sensorpajen_2.0.0_armhf.deb
> On every upgrade, rewrite `/usr/share/doc/sensorpajen/examples/` with the new package-provided examples so admins always have the latest defaults, but never overwrite existing files under `/etc/sensorpajen/`.
### Automated Verification
Provide a script (e.g., `scripts/verify-deb.sh`) that runs the build and linting steps in a clean environment. The script should:
```
#!/bin/bash
set -e
./ci/build-debian.sh # builds the deb into a temp directory
lintian sensorpajen_*.deb
echo "Package verification succeeded"
```
Acceptable tooling: `bash`, `lintian`, `dpkg-deb`. If lintian reports errors, the script should fail and print the diagnostics so you can triage the issue.
```
#### 10. Installation Test
```bash
# Install
sudo dpkg -i sensorpajen_2.0.0_armhf.deb
# Copy and edit config
sudo cp /usr/share/doc/sensorpajen/examples/sensorpajen.env.example /etc/sensorpajen/sensorpajen.env
sudo cp /usr/share/doc/sensorpajen/examples/sensors.json.example /etc/sensorpajen/sensors.json
sudo nano /etc/sensorpajen/sensorpajen.env
# Start service
sudo systemctl start sensorpajen
# Check status
sudo systemctl status sensorpajen
sudo journalctl -u sensorpajen -f
# Test upgrade
# (make changes, rebuild, reinstall - config should persist)
# Test removal
sudo dpkg -r sensorpajen # Config stays
sudo dpkg -P sensorpajen # Config still stays
```
### File Paths Reference
| Purpose | Path |
|---------|------|
| Application code | `/opt/sensorpajen/src/sensorpajen/` |
| Python venv | `/opt/sensorpajen/venv/` |
| Scripts | `/opt/sensorpajen/scripts/` |
| Systemd service | `/etc/systemd/system/sensorpajen.service` |
| Active config | `/etc/sensorpajen/sensorpajen.env`, `/etc/sensorpajen/sensors.json` |
| Discovery data | `/etc/sensorpajen/discovered_sensors.json` |
| Example configs | `/usr/share/doc/sensorpajen/examples/*.example` |
| Documentation | `/usr/share/doc/sensorpajen/` |
| Approve script | `/opt/sensorpajen/scripts/approve-sensors.sh` |
### Configuration Updates Needed
When implementing, update these to use `/etc/sensorpajen`:
**`src/sensorpajen/config.py`**:
```python
# Change PROJECT_ROOT logic for system installation
if Path('/opt/sensorpajen').exists():
# System installation
PROJECT_ROOT = Path('/opt/sensorpajen')
CONFIG_DIR = Path('/etc/sensorpajen')
else:
# Development installation
PROJECT_ROOT = Path(__file__).parent.parent.parent
CONFIG_DIR = PROJECT_ROOT / "config"
```
**`scripts/approve-sensors.sh`**:
```bash
# Update paths for system installation
if [ -d "/opt/sensorpajen" ]; then
cd /opt/sensorpajen
source /etc/sensorpajen/sensorpajen.env
source venv/bin/activate
else
# Development mode
cd "$(dirname "$0")/.."
source config/sensorpajen.env
source .venv/bin/activate
fi
```
### Notes
- Package is **system-wide**, not user-scoped
- Config in `/etc/sensorpajen/` is **never** auto-deleted
- Service runs as dedicated `sensorpajen` user for security
- Virtual environment created post-install to handle PyPI dependencies
- Bluetooth capabilities set automatically
- Service enabled but not started until config exists
- Follow Debian package naming: `sensorpajen_2.0.0_armhf.deb`
- Test on fresh Pi before considering complete
---
## Task: Add Auto-Discovery and Approval Flow for Sensors
### Problem Statement
Adding new sensors currently requires manually editing `sensors.json`, which is error-prone and inconvenient.
The system should automatically detect new sensors and provide a controlled way for users to approve or ignore them.
---
## Goal
Implement **automatic sensor discovery** with a **user approval workflow** that:
* Detects new sensors automatically
* Notifies the user when new sensors are discovered
* Allows the user to approve or ignore sensors via a script
* Automatically updates `sensors.json` for approved sensors
* Restarts the service after configuration changes
---
## Scope
### In Scope
* Sensor auto-discovery
* Tracking newly discovered sensors
* Notification via `ntfy`
* Interactive user script for approving/ignoring sensors
* Updating `sensors.json`
* Restarting the service via systemd
### Out of Scope
* Web UI
* Authentication mechanisms beyond existing system access
* Changes to sensor hardware or firmware
* Long-term sensor management (removal, editing, etc.)
---
## Functional Requirements
### 1. Sensor Auto-Discovery
* The service must detect sensors that are not present in `sensors.json`
* Each newly discovered sensor must have a stable unique identifier
* Discovered-but-unapproved sensors must **not** be added automatically
---
### 2. Discovered Sensor Storage
* Newly discovered sensors must be stored in `config/discovered_sensors.json`
* Stored data must include:
* `mac` - MAC address (unique identifier)
* `name` - Advertised device name (e.g., "ATC_1234AB")
* `rssi` - Signal strength in dBm
* `first_seen` - ISO timestamp of first discovery
* `last_seen` - ISO timestamp of most recent advertisement
* `sample_reading` - One example reading with temperature, humidity, battery data
* `status` - One of: "pending", "approved", "ignored"
* `ignored_at` - ISO timestamp when ignored (if status is "ignored")
* `ignore_reason` - Optional user-provided reason for ignoring
* Approved sensors must have their status updated to "approved"
* Ignored sensors must remain in the file with status "ignored"
---
### 3. Notification via ntfy
* When a new sensor is discovered:
* Send a notification to the configured `ntfy` topic via curl
* Include at least:
* Sensor MAC address
* Sensor name
* Last seen timestamp
* Instruction that user action is required
* Configuration (in `config/sensorpajen.env`):
* `NTFY_ENABLED` - true/false to enable/disable notifications
* `NTFY_URL` - ntfy server URL (e.g., "https://ntfy.sh")
* `NTFY_TOPIC` - Topic to publish to
* `NTFY_TOKEN` - Authentication token (sent in header)
* ntfy is optional - system must work without it:
* If `NTFY_ENABLED=false`, skip notifications
* If ntfy is unreachable, log error and continue
* Discovery and approval must work even if ntfy fails
* The user must only be notified once per discovered sensor
---
### 4. User Approval Script
Provide a CLI command `sensorpajen approve-sensors` that:
* Lists all sensors with status "pending" or "ignored"
* For each sensor, displays:
* MAC address
* Advertised name (e.g., "ATC_1234AB")
* Last seen timestamp
* Sample reading (temperature, humidity, battery)
* Current status (pending/ignored)
* For each sensor, allows the user to:
* Approve the sensor (add to `sensors.json`)
* Ignore the sensor (mark as ignored)
* Skip (leave as pending for later)
* If approving:
* Prompt for a sensor name (required, human-readable)
* Pre-fill comment field with extended metadata (MAC, device name, last seen, sample reading)
* Allow user to edit or keep the pre-filled comment (optional)
* If ignoring:
* Prompt for optional reason
* Update status to "ignored" with timestamp
* Interactive mode only (no batch/automated approval)
---
### 5. Updating sensors.json
* When a sensor is approved:
* Add it to `sensors.json` (only if MAC doesn't already exist)
* Include:
* `mac` - MAC address from discovery
* `name` - User-provided human-readable name
* `comment` - User-edited comment (pre-filled with metadata)
* The file must remain valid JSON
* Existing sensors must not be modified
* If MAC already exists in `sensors.json`, skip adding (renaming is done manually in the file)
* Update status to "approved" in `discovered_sensors.json`
---
### 6. Configuration Reload
* The service must automatically reload `sensors.json` every 15 minutes
* No service restart required after approval
* If `sensors.json` is modified:
* Load new sensor list
* Start monitoring newly added sensors
* Continue monitoring existing sensors without interruption
* Log configuration reload events
---
## Non-Functional Requirements
* Must be safe to run on a Raspberry Pi
* Must not require a GUI
* Must fail gracefully if:
* `ntfy` is unreachable
* The user aborts the approval script
* Logging must clearly indicate:
* Discovery events
* Notifications sent
* Approval or ignore decisions
---
## Acceptance Criteria
* A new sensor is automatically detected and added to `discovered_sensors.json` with status "pending"
* Extended metadata (MAC, name, RSSI, timestamps, sample reading) is stored
* A notification is sent via `ntfy` when a sensor is discovered (if enabled)
* The approval CLI command (`sensorpajen approve-sensors`) lists pending and ignored sensors
* The CLI displays MAC, name, last seen, and sample reading for each sensor
* The user can approve a sensor with a custom name
* The comment field is pre-filled with metadata and user can edit it
* The user can ignore a sensor with an optional reason
* Previously ignored sensors can be approved in a later CLI run
* Approved sensors appear correctly in `sensors.json` (mac + name + comment only)
* Sensors already in `sensors.json` are not added again (no duplicates)
* The service automatically reloads `sensors.json` every 15 minutes
* New sensors are monitored without service restart
* Ignored sensors are stored with `ignored_at` timestamp and optional `ignore_reason`
* ntfy failures do not prevent discovery or approval workflow
---
## Notes for Implementation
* Prefer environment-based configuration (no `.ini` files)
* Keep the discovery logic separate from user interaction logic
* Avoid race conditions between discovery and approval
* Assume multiple sensors may be discovered before user action
* Use MAC address as unique identifier for sensors
* ntfy notification format: `curl -H "Authorization: Bearer $NTFY_TOKEN" -d "message" $NTFY_URL/$NTFY_TOPIC`
* Config reload: Use a timer thread that checks file mtime or reloads every 15 minutes
* Pre-filled comment example: `"MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:30:00, Temp: 21.5°C, Humidity: 45%, Battery: 87%"`
---
## Implementation Details
### File Locations
* Discovered sensors: `config/discovered_sensors.json`
* Known sensors: `config/sensors.json` (existing)
* Configuration: `config/sensorpajen.env` (add ntfy settings)
### New CLI Command
* Entry point: `sensorpajen approve-sensors`
* Add to `pyproject.toml` under `[project.scripts]`
### Configuration Variables (add to sensorpajen.env)
```bash
# ntfy notifications (optional)
NTFY_ENABLED=true
NTFY_URL=https://ntfy.sh
NTFY_TOPIC=sensorpajen
NTFY_TOKEN=tk_xxxxxxxxxxxxx
# Config reload interval (seconds)
CONFIG_RELOAD_INTERVAL=900 # 15 minutes
```
### discovered_sensors.json Structure
```json
[
{
"mac": "A4:C1:38:12:34:56",
"name": "ATC_1234AB",
"rssi": -65,
"first_seen": "2025-12-27T14:30:15",
"last_seen": "2025-12-27T14:35:42",
"sample_reading": {
"temperature": 21.5,
"humidity": 45,
"battery_percent": 87,
"battery_voltage": 2950
},
"status": "pending"
},
{
"mac": "A4:C1:38:AB:CD:EF",
"name": "ATC_ABCDEF",
"rssi": -72,
"first_seen": "2025-12-27T15:00:00",
"last_seen": "2025-12-27T15:10:00",
"sample_reading": {
"temperature": 19.8,
"humidity": 52,
"battery_percent": 65,
"battery_voltage": 2800
},
"status": "ignored",
"ignored_at": "2025-12-27T15:15:00",
"ignore_reason": "Test sensor, not needed"
}
]
```
### sensors.json Entry (after approval)
```json
{
"mac": "A4:C1:38:12:34:56",
"name": "Living Room",
"comment": "MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:35:42, Temp: 21.5°C, Humidity: 45%, Battery: 87%"
}
```
---
If you want, I can also:
* Split this into **multiple smaller tasks**
* Add a **definition of done** section
* Provide a **suggested file/module structure**
* Write a **follow-up roadmap entry** for sensor management
Just tell me how you want to evolve it next.
## Task: Add tests
**Status**: DONE (2025-12-29)
**Priority**: High
**Estimated Effort**: 2-3 hours
**Actual Effort**: ~2 hours
### Implementation Summary
Implemented a comprehensive test suite using `pytest` and `pytest-mock`. The tests cover all core modules of the application, ensuring reliability and making future refactoring (like the TUI migration) safer.
### Key Features Implemented
- **Unit Tests for config.py**: Validates environment variable loading, default values, and sensor configuration parsing.
- **Unit Tests for mqtt_publisher.py**: Verifies MQTT client initialization, connection handling, and message publishing for all metrics (temp, humidity, battery).
- **Unit Tests for sensor_reader.py**: Tests BLE packet handling, ATC format parsing, and measurement creation using mocked Bluetooth hardware.
- **Unit Tests for discovery_manager.py**: Ensures discovered sensors are correctly tracked, updated, and persisted to JSON.
- **Test Infrastructure**: Added `conftest.py` for global mocks (Bluetooth, Environment) and configured `pyproject.toml` with dev dependencies.
### Testing Results
- ✅ 17 tests passed across 4 test files.
- ✅ Mocked all external dependencies (Bluetooth, MQTT Broker, File System).
- ✅ Verified correct handling of both known and unknown sensors.

564
ROADMAP-v2.md Normal file
View File

@@ -0,0 +1,564 @@
# ROADMAP: Modernizing Sensorpajen
## Overview
This roadmap outlines the migration from the current tmux/cron-based system to a modern systemd service running on Raspberry Pi.
**Migration Date**: Started December 27, 2025
**Target Completion**: TBD
---
## Current State
### What We Have
- LYWSD03MMC.py: Main Bluetooth sensor reader
- temperatur_koksfonstret.py: DHT11 sensor reader (to be removed)
- bluetooth_utils.py: Bluetooth utility functions
- sensorer.ini: MAC address to sensor name mapping
- sendToMQTT.sh: MQTT publishing callback (hardcoded credentials)
- startup.sh/sensorer.sh: tmux-based startup scripts
- Cron jobs for scheduling
### Known Issues
- MQTT credentials hardcoded in shell scripts
- Legacy pirate_audio references in startup.sh
- Manual tmux orchestration
- Mixed configuration sources
- DHT11 functionality to be removed
---
## Target Architecture
### Final Structure
```
sensorpajen/
├── src/
│ └── sensorpajen/
│ ├── __init__.py
│ ├── main.py # Entry point
│ ├── config.py # Configuration management
│ ├── sensor_reader.py # Bluetooth sensor logic
│ ├── mqtt_publisher.py # MQTT publishing
│ └── utils.py # Utilities (from bluetooth_utils.py)
├── config/ # Configuration directory (relative)
│ ├── sensors.json.example # Sensor mapping template
│ ├── sensorpajen.env.example # Environment file template
│ ├── sensors.json # Actual sensor mapping (not in git)
│ └── sensorpajen.env # Actual environment file (not in git)
├── debian/ # APT package files
│ ├── control
│ ├── rules
│ ├── changelog
│ └── ... # Other Debian package files
├── pyproject.toml # Project metadata and dependencies
├── requirements.txt # Dependencies (bluepy, paho-mqtt)
├── README.md # Updated documentation
├── AGENTS.md # Agent guidelines
├── ROADMAP.md # This file
├── legacy/ # Legacy scripts (moved here temporarily)
│ ├── LYWSD03MMC.py
│ ├── temperatur_koksfonstret.py
│ ├── sendToMQTT.sh
│ ├── startup.sh
│ ├── sensorer.sh
│ └── sensorer.ini
└── systemd/
├── sensorpajen.service # Systemd service unit
└── README.md # Systemd installation instructions
```
### Configuration Strategy
Using relative paths for portability across systems:
1. **Sensor Mapping**: `config/sensors.json` (relative to project root)
- Maps MAC addresses to sensor names
- JSON format for Python ease
- Not committed to git (use sensors.json.example as template)
2. **MQTT Credentials**: `config/sensorpajen.env` (relative to project root)
- Contains sensitive MQTT configuration
- Permissions: 0600 (owner read/write only)
- Not committed to git (use sensorpajen.env.example as template)
3. **Environment Variables** (via systemd EnvironmentFile):
```
MQTT_HOST=192.168.0.114
MQTT_USER=hasse
MQTT_PASSWORD=casablanca
MQTT_CLIENT_ID=mibridge
SENSOR_CONFIG_FILE=config/sensors.json
```
4. **Git Ignore**: Add to .gitignore:
```
config/sensors.json
config/sensorpajen.env
```
---
## Migration Phases
### Phase 1: Preparation & Cleanup ✅ DONE (2025-12-27)
**Goal**: Reorganize repository without breaking existing functionality
**Notes**:
- Created modern Python package structure with src/ layout
- Converted INI sensor config to JSON format (sensors.json.example)
- Environment-based configuration instead of hardcoded values
- DHT11 sensor functionality removed as planned
- Legacy scripts preserved in legacy/ folder
#### Tasks:
- ✅ Create new directory structure
- ✅ Create pyproject.toml with dependencies
- ✅ Remove DHT11 functionality
- ✅ Move legacy scripts to legacy/ folder
- ✅ Create config file templates (sensors.json.example, sensorpajen.env.example)
- ✅ Preserve requirements.txt for backward compatibility
---
### Phase 2: Python Package Structure ✅ DONE (2025-12-27)
**Goal**: Create modern Python package with proper entry point
**Notes**:
- Used src/ layout for better packaging practices
- Direct Python MQTT integration (no shell script callbacks)
- ATC firmware BLE advertisement reading (passive scanning)
- Watchdog thread for BLE connection recovery
- Clean separation of concerns (config, MQTT, sensors, main)
#### Tasks:
- ✅ Created src/sensorpajen/__init__.py with version info
- ✅ Created src/sensorpajen/config.py
- Environment variable loading with validation
- SensorConfig class for JSON sensor mapping
- Relative path resolution (PROJECT_ROOT)
- Configuration validation and logging
- ✅ Created src/sensorpajen/utils.py
- Ported bluetooth_utils.py (MIT licensed, Colin GUYON)
- BLE scanning and advertisement parsing
- ✅ Created src/sensorpajen/mqtt_publisher.py
- MQTTPublisher class with connection management
- Direct publishing (replaces sendToMQTT.sh)
- Automatic reconnection support
- Battery data publishing (optional)
- ✅ Created src/sensorpajen/sensor_reader.py
- SensorReader class for BLE scanning
- ATC packet parsing
- Duplicate packet filtering
- Watchdog for BLE recovery
- Measurement dataclass
- ✅ Created src/sensorpajen/main.py
- Application entry point
- Signal handling (SIGTERM, SIGINT)
- Graceful shutdown
- Logging to stdout for journald
---
### Phase 3: Configuration Migration ✅ DONE (2025-12-27)
**Goal**: Replace .ini file with JSON and environment variables
**Notes**: Templates created in Phase 1, successfully tested on Raspberry Pi
#### Tasks:
1. Create sensor mapping converter script
- Read sensorer.ini
- Output to sensors.json
```json
{
"sensors": [
{
"mac": "A4:C1:38:98:7B:B6",
"name": "mi_temp_1"
},
{
"mac": "A4:C1:38:29:03:0D",
"name": "mi_temp_2"
}
]
}
```
configuration file templates
- `config/sensorpajen.env.example`
```bash
# MQTT Configuration
MQTT_HOST=192.168.0.114
MQTT_PORT=1883
MQTT_USER=hasse
MQTT_PASSWORD=casablanca
MQTT_CLIENT_ID=mibridge
# Sensor Configuration (relative to project root)
SENSOR_CONFIG_FILE=config/sensors.json
# Application Settings
WATCHDOG_TIMEOUT=5
ENABLE_BATTERY=true
LOG_LEVEL=INFO
```
- `config/sensors.json.example`
```json
{
"sensors": [
{
"mac": "A4:C1:38:98:7B:B6",
"name": "mi_temp_1",
"comment": "Example sensor"
}
]
}
```
3. Copy templates to actual config files (not in git):
```bash
cp config/sensorpajen.env.example config/sensorpajen.env
cp config/sensors.json.example config/sensors.json
chmod 600 config/sensorpajen.env
# Edit both files with your actual configurationnsorpajen/sensorpajen.env
chmod 600 /home/fredrik/.config/sensorpajen/sensorpajen.env
```
4. Document all configuration variables in README
---
config/sensorpajen.env
config/sensors.json
*.deb
debian/.debhelper/
debian/sensorpajen/
debian/files
debian/*.log
debian/*.substvars
### Phase 4: Virtual Environment & Dependencies ✅ DONE (2025-12-27)
**Goal**: Set up isolated Python environment
**Notes**: Tested on Raspberry Pi, paho-mqtt v2.x compatibility fixed
#### Tasks:
1. Create virtual environment:
```bash
python3 -m venv .venv
```
2. Update .gitignore:
```
.venv/
__pycache__/
*.pyc
.env
sensorpajen.env
```
3. Install dependencies:
```bash
source .venv/bin/activate
pip install --upgrade pip
pip install bluepy paho-mqtt
pip install -e . # Install package in development mode
```
4. Document virtual environment usage in README
---✅ DONE (2025-12-27)
**Goal**: Allow non-root user to access Bluetooth
**Notes**: Tested on Raspberry Pi with setcap on actual Python binary
### Phase 5: Bluetooth Permissions ✅ DONE (2025-12-27)
**Goal**: Allow non-root user to access Bluetooth
**Notes**: Tested on Raspberry Pi with setcap on actual Python binary
#### Tasks:
- ✅ Bluetooth capabilities set with setcap
- ✅ Documented in SETUP_ON_PI.md with correct readlink -f usage
- ✅ Tested successfully on Raspberry Pi
---
### Phase 6: Systemd Service Creation ✅ DONE (2025-12-27)
**Goal**: Create and configure systemd user service
**Notes**:
- User service for easier management (no sudo required)
- Service ready for installation on Raspberry Pi
- Comprehensive documentation provided
- **Important discoveries**:
- `AmbientCapabilities` does NOT work in user services (only system services)
- Must use `setcap` on the Python binary instead
- `NoNewPrivileges=true` prevents file capabilities from working - must be disabled
- Capabilities must be set on actual binary, not symlinks: `setcap ... $(readlink -f python3)`
#### Tasks:
- ✅ Created systemd/sensorpajen.service
- ✅ Created systemd/README.md with full documentation
- ✅ Service management and troubleshooting guides included
- ✅ Tested and verified working on Raspberry Pi
---
### Phase 7: Testing & Validation ✅ DONE (2025-12-27)
**Goal**: Verify new service works before removing legacy
**Notes**:
- Service tested and running successfully
- Legacy cron/tmux system stopped
- All sensors reporting correctly via systemd service
#### Tasks:
- ✅ Stopped legacy cron/tmux processes
- ✅ Started new systemd service
- ✅ Monitored logs - no errors
- ✅ Verified all 8 sensors reporting
- ✅ Confirmed MQTT publishing working
- ✅ Tested service restart and auto-recovery
---
### Phase 8: APT Package Creation ✅ DONE (2025-12-27)
**Goal**: Create Debian package for easy installation on Raspberry Pi
**Notes**:
- Complete debian/ directory structure created
- System-wide installation to /opt/sensorpajen
- Configuration in /etc/sensorpajen
- Dedicated sensorpajen system user
- Automatic venv creation in postinst
- Bluetooth capabilities set automatically
- Config preserved on remove/purge for safety
- Dual-mode support: system installation and development
- config.py auto-detects installation type
#### Files Created:
- ✅ debian/control - Package metadata and dependencies
- ✅ debian/compat - Debhelper compatibility level
- ✅ debian/changelog - Package version history
- ✅ debian/rules - Build instructions
- ✅ debian/install - File installation mappings
- ✅ debian/postinst - Post-installation script (user, venv, setcap)
- ✅ debian/prerm - Pre-removal script (stop service)
- ✅ debian/postrm - Post-removal script (cleanup)
- ✅ debian/sensorpajen.service - System-wide systemd unit
#### Code Updates:
- ✅ Updated src/sensorpajen/config.py to detect system installation
- Checks for /opt/sensorpajen existence
- Uses /etc/sensorpajen for config in system mode
- Falls back to PROJECT_ROOT/config for development
- ✅ Updated scripts/approve-sensors.sh for dual-mode operation
- Detects system vs development installation
- Uses correct venv and config paths
- ✅ Created scripts/verify-deb.sh - Automated build and verification
#### Package Details:
- Package name: sensorpajen
- Version: 2.0.0-dev
- Architecture: all
- System paths:
- Application: /opt/sensorpajen/
- Configuration: /etc/sensorpajen/
- Service file: /etc/systemd/system/sensorpajen.service
- Examples: /usr/share/doc/sensorpajen/examples/
- Runs as dedicated sensorpajen user (system account)
- Auto-enables service but waits for configuration before starting
#### Build and Test:
```bash
# Build package
./scripts/verify-deb.sh
# Or manually:
dpkg-buildpackage -us -uc -b
lintian ../sensorpajen_*.deb
# Install on Raspberry Pi:
scp ../sensorpajen_*.deb pi@raspberrypi:~/
ssh pi@raspberrypi
sudo apt install ./sensorpajen_*.deb
# Configure:
sudo nano /etc/sensorpajen/sensorpajen.env
sudo nano /etc/sensorpajen/sensors.json
# Start:
sudo systemctl start sensorpajen
sudo journalctl -u sensorpajen -f
```
---
### Phase 9: Cleanup & Documentation ✅ DONE (2025-12-27)
**Goal**: Remove legacy code and finalize documentation
**Notes**:
- Legacy cron/tmux scripts removed
- Documentation focused on practical usage
- INSTALL.md created for sysadmins
#### Tasks:
- ✅ Deleted legacy/ folder (old cron/tmux scripts)
- ✅ Created INSTALL.md with concise installation guide
- ✅ Updated README.md troubleshooting section
- ✅ Documentation assumes sysadmin familiarity
---
## Migration Complete! 🎉
All phases completed. The system has been successfully migrated from a legacy cron/tmux-based system to a modern systemd service with:
- ✅ Python package structure
- ✅ Environment-based configuration (no .ini files)
- ✅ Systemd user service with auto-restart
- ✅ Automatic sensor discovery with approval workflow
- ✅ Configuration auto-reload (no restart needed)
- ✅ ntfy notifications for new sensors
- ✅ Comprehensive documentation
**Version**: 2.0.0-dev
**Status**: Production-ready
```markdown
## Installation
### 1. Clone Repository
git clone <repo> /home/fredrik/dev/sensorpajen
cd /home/fredrik/dev/sensorpajen
### 2. Create Virtual Environment
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
### 3. Configure
mkdir -p ~/.config/sensorpajen
cp systemd/sensorpajen.env.example ~/.config/sensorpajen/sensorpajen.env
# Edit configuration
nano ~/.config/sensorpajen/sensorpajen.env
chmod 600 ~/.config/sensorpajen/sensorpajen.env
### 4. Convert Sensor Configuration
# Create sensors.json from your sensor list
### 5. Install Service
cp systemd/sensorpajen.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable sensorpajen
systemctl --user start sensorpajen
### 6. Verify
systemctl --user status sensorpajen
journalctl --user -u sensorpajen -f
```
5. Add troubleshooting section:
- Bluetooth permission issues
- MQTT connection problems
- Service won't start
- Log locations
---
## Configuration File Locations (Linux Best Practices)
### User Service Configuration
- **Service files**: `~/.config/systemd/user/`
- **Application config**: `~/.config/sensorpajen/`
- **Environment file**: `~/.config/sensorpajen/sensorpajen.env` (0600)
- **Sensor mapping**: `~/.config/sensorpajen/sensors.json` (0644)
### System Service (Alternative - Not Recommended)
If running as system service (not user service):
- **Service file**: `/etc/systemd/system/sensorpajen.service`
- **Config directory**: `/etc/sensorpajen/`
- **Environment file**: `/etc/sensorpajen/sensorpajen.env` (0600)
**Recommendation**: Use user service (current approach) since:
- No sudo required for service management
- Easier permission management
- Better security isolation
- Simpler Bluetooth access
---
## Success Criteria
The migration is complete when:
- ✅ Service starts automatically on boot
- ✅ All 8 Bluetooth sensors are being read
- ✅ MQTT messages are published correctly
- ✅ Service recovers automatically from crashes
- ✅ No hardcoded credentials in code
- ✅ Logs are visible via journalctl
- ✅ DHT11 functionality completely removed
- ✅ Legacy scripts removed
- ✅ Documentation is complete and accurate
- ✅ Service runs as user (not root)
- ✅ Virtual environment is working
---
## Rollback Plan
If issues arise during migration:
1. Stop new service:
```bash
systemctl --user stop sensorpajen
systemctl --user disable sensorpajen
```
2. Restore legacy scripts from legacy/ folder:
```bash
cp legacy/* .
```
3. Restore cron jobs:
```bash
crontab -e
# Uncomment:
# @reboot /home/fredrik/dev/sensorpajen/sensorer.sh
```
4. Reboot or manually start tmux session
---
## Future Enhancements
After successful migration, consider:
- [ ] Add Prometheus metrics endpoint
- [ ] Add systemd watchdog support
- [ ] Implement graceful sensor failure handling
- [ ] Add MQTT TLS support
- [ ] Create web dashboard for sensor status
- [ ] Add sensor calibration configuration
- [ ] Implement sensor auto-discovery
- [ ] Add health check endpoint
---
## Notes
- Keep legacy scripts during migration for safety
- Test thoroughly before removing cron jobs
- Monitor for at least 1-2 weeks before final cleanup
- Document any issues encountered during migration
- Take notes of actual MAC addresses and sensor names during conversion
---
## References
- systemd user services: `man systemd.service`
- XDG Base Directory: `~/.config/` for user configuration
- Bluetooth capabilities: `man capabilities`
- journalctl: `man journalctl`
- Python logging: https://docs.python.org/3/library/logging.html

View File

@@ -1,631 +1,44 @@
# ROADMAP: Modernizing Sensorpajen
# ROADMAP: Sensorpajen Modernization & TUI
## Overview
This roadmap outlines the migration from the current tmux/cron-based system to a modern systemd service running on Raspberry Pi.
**Migration Date**: Started December 27, 2025
**Target Completion**: TBD
This roadmap defines the evolution of Sensorpajen from a CLI-based tool to a full-featured TUI application for sensor management and monitoring.
---
## Current State
### What We Have
- LYWSD03MMC.py: Main Bluetooth sensor reader
- temperatur_koksfonstret.py: DHT11 sensor reader (to be removed)
- bluetooth_utils.py: Bluetooth utility functions
- sensorer.ini: MAC address to sensor name mapping
- sendToMQTT.sh: MQTT publishing callback (hardcoded credentials)
- startup.sh/sensorer.sh: tmux-based startup scripts
- Cron jobs for scheduling
### Known Issues
- MQTT credentials hardcoded in shell scripts
- Legacy pirate_audio references in startup.sh
- Manual tmux orchestration
- Mixed configuration sources
- DHT11 functionality to be removed
---
## Target Architecture
### Final Structure
```
sensorpajen/
├── src/
│ └── sensorpajen/
│ ├── __init__.py
│ ├── main.py # Entry point
│ ├── config.py # Configuration management
│ ├── sensor_reader.py # Bluetooth sensor logic
│ ├── mqtt_publisher.py # MQTT publishing
│ └── utils.py # Utilities (from bluetooth_utils.py)
├── config/ # Configuration directory (relative)
│ ├── sensors.json.example # Sensor mapping template
│ ├── sensorpajen.env.example # Environment file template
│ ├── sensors.json # Actual sensor mapping (not in git)
│ └── sensorpajen.env # Actual environment file (not in git)
├── debian/ # APT package files
│ ├── control
│ ├── rules
│ ├── changelog
│ └── ... # Other Debian package files
├── pyproject.toml # Project metadata and dependencies
├── requirements.txt # Dependencies (bluepy, paho-mqtt)
├── README.md # Updated documentation
├── AGENTS.md # Agent guidelines
├── ROADMAP.md # This file
├── legacy/ # Legacy scripts (moved here temporarily)
│ ├── LYWSD03MMC.py
│ ├── temperatur_koksfonstret.py
│ ├── sendToMQTT.sh
│ ├── startup.sh
│ ├── sensorer.sh
│ └── sensorer.ini
└── systemd/
├── sensorpajen.service # Systemd service unit
└── README.md # Systemd installation instructions
```
### Configuration Strategy
Using relative paths for portability across systems:
1. **Sensor Mapping**: `config/sensors.json` (relative to project root)
- Maps MAC addresses to sensor names
- JSON format for Python ease
- Not committed to git (use sensors.json.example as template)
2. **MQTT Credentials**: `config/sensorpajen.env` (relative to project root)
- Contains sensitive MQTT configuration
- Permissions: 0600 (owner read/write only)
- Not committed to git (use sensorpajen.env.example as template)
3. **Environment Variables** (via systemd EnvironmentFile):
```
MQTT_HOST=192.168.0.114
MQTT_USER=hasse
MQTT_PASSWORD=casablanca
MQTT_CLIENT_ID=mibridge
SENSOR_CONFIG_FILE=config/sensors.json
```
4. **Git Ignore**: Add to .gitignore:
```
config/sensors.json
config/sensorpajen.env
```
---
## Migration Phases
### Phase 1: Preparation & Cleanup ✅ DONE (2025-12-27)
**Goal**: Reorganize repository without breaking existing functionality
## Phase 1: Modern TUI Management & Data Persistence ✅ DONE (2025-12-29)
**Goal**: Replace the basic CLI with a full-screen Textual TUI and improve discovery data persistence.
**Notes**:
- Created modern Python package structure with src/ layout
- Converted INI sensor config to JSON format (sensors.json.example)
- Environment-based configuration instead of hardcoded values
- DHT11 sensor functionality removed as planned
- Legacy scripts preserved in legacy/ folder
- Migrated discovery data to SQLite for better metadata tracking.
- Implemented a full-screen TUI using Textual with Discovery, Configured, and Ignored views.
- Added support for interactive Approve, Ignore, Edit, and Remove actions.
#### Tasks:
- ✅ Create new directory structure
- ✅ Create pyproject.toml with dependencies
- ✅ Remove DHT11 functionality
- ✅ Move legacy scripts to legacy/ folder
- ✅ Create config file templates (sensors.json.example, sensorpajen.env.example)
- ✅ Preserve requirements.txt for backward compatibility
### Tasks:
-**Database Migration**: Replace `discovered_sensors.json` with a SQLite database.
-**Textual TUI Scaffolding**: Initialize a full-screen TUI using the `Textual` library.
-**Sensor Management View**: Interactive management of all sensor states.
-**Branching Strategy**: Developed in `feature/tui-management`.
---
### Phase 2: Python Package Structure ✅ DONE (2025-12-27)
**Goal**: Create modern Python package with proper entry point
## Phase 2: Live Monitoring & Global Configuration
**Goal**: Add real-time visibility and full system configuration to the TUI.
**Notes**:
- Used src/ layout for better packaging practices
- Direct Python MQTT integration (no shell script callbacks)
- ATC firmware BLE advertisement reading (passive scanning)
- Watchdog thread for BLE connection recovery
- Clean separation of concerns (config, MQTT, sensors, main)
#### Tasks:
- ✅ Created src/sensorpajen/__init__.py with version info
- ✅ Created src/sensorpajen/config.py
- Environment variable loading with validation
- SensorConfig class for JSON sensor mapping
- Relative path resolution (PROJECT_ROOT)
- Configuration validation and logging
- ✅ Created src/sensorpajen/utils.py
- Ported bluetooth_utils.py (MIT licensed, Colin GUYON)
- BLE scanning and advertisement parsing
- ✅ Created src/sensorpajen/mqtt_publisher.py
- MQTTPublisher class with connection management
- Direct publishing (replaces sendToMQTT.sh)
- Automatic reconnection support
- Battery data publishing (optional)
- ✅ Created src/sensorpajen/sensor_reader.py
- SensorReader class for BLE scanning
- ATC packet parsing
- Duplicate packet filtering
- Watchdog for BLE recovery
- Measurement dataclass
- ✅ Created src/sensorpajen/main.py
- Application entry point
- Signal handling (SIGTERM, SIGINT)
- Graceful shutdown
- Logging to stdout for journald
### Tasks:
- [ ] **Live Dashboard**:
- Real-time display of temperature, humidity, and battery levels.
- Visual indicators for sensor health/connectivity.
- [ ] **Global Configuration**:
- Edit MQTT settings (Host, Port, Credentials).
- Edit application settings (Watchdog, Log Level, etc.).
- [ ] **System Integration**:
- View service logs within the TUI.
- Restart/Stop service from the TUI.
---
### Phase 3: Configuration Migration ✅ DONE (2025-12-27)
**Goal**: Replace .ini file with JSON and environment variables
**Notes**: Templates created in Phase 1, successfully tested on Raspberry Pi
#### Tasks:
1. Create sensor mapping converter script
- Read sensorer.ini
- Output to sensors.json
```json
{
"sensors": [
{
"mac": "A4:C1:38:98:7B:B6",
"name": "mi_temp_1"
},
{
"mac": "A4:C1:38:29:03:0D",
"name": "mi_temp_2"
}
]
}
```
configuration file templates
- `config/sensorpajen.env.example`
```bash
# MQTT Configuration
MQTT_HOST=192.168.0.114
MQTT_PORT=1883
MQTT_USER=hasse
MQTT_PASSWORD=casablanca
MQTT_CLIENT_ID=mibridge
# Sensor Configuration (relative to project root)
SENSOR_CONFIG_FILE=config/sensors.json
# Application Settings
WATCHDOG_TIMEOUT=5
ENABLE_BATTERY=true
LOG_LEVEL=INFO
```
- `config/sensors.json.example`
```json
{
"sensors": [
{
"mac": "A4:C1:38:98:7B:B6",
"name": "mi_temp_1",
"comment": "Example sensor"
}
]
}
```
3. Copy templates to actual config files (not in git):
```bash
cp config/sensorpajen.env.example config/sensorpajen.env
cp config/sensors.json.example config/sensors.json
chmod 600 config/sensorpajen.env
# Edit both files with your actual configurationnsorpajen/sensorpajen.env
chmod 600 /home/fredrik/.config/sensorpajen/sensorpajen.env
```
4. Document all configuration variables in README
---
config/sensorpajen.env
config/sensors.json
*.deb
debian/.debhelper/
debian/sensorpajen/
debian/files
debian/*.log
debian/*.substvars
### Phase 4: Virtual Environment & Dependencies ✅ DONE (2025-12-27)
**Goal**: Set up isolated Python environment
**Notes**: Tested on Raspberry Pi, paho-mqtt v2.x compatibility fixed
#### Tasks:
1. Create virtual environment:
```bash
python3 -m venv .venv
```
2. Update .gitignore:
```
.venv/
__pycache__/
*.pyc
.env
sensorpajen.env
```
3. Install dependencies:
```bash
source .venv/bin/activate
pip install --upgrade pip
pip install bluepy paho-mqtt
pip install -e . # Install package in development mode
```
4. Document virtual environment usage in README
---✅ DONE (2025-12-27)
**Goal**: Allow non-root user to access Bluetooth
**Notes**: Tested on Raspberry Pi with setcap on actual Python binary
### Phase 5: Bluetooth Permissions ✅ DONE (2025-12-27)
**Goal**: Allow non-root user to access Bluetooth
**Notes**: Tested on Raspberry Pi with setcap on actual Python binary
#### Tasks:
- ✅ Bluetooth capabilities set with setcap
- ✅ Documented in SETUP_ON_PI.md with correct readlink -f usage
- ✅ Tested successfully on Raspberry Pi
---
### Phase 6: Systemd Service Creation ✅ DONE (2025-12-27)
**Goal**: Create and configure systemd user service
**Notes**:
- User service for easier management (no sudo required)
- Service ready for installation on Raspberry Pi
- Comprehensive documentation provided
- **Important discoveries**:
- `AmbientCapabilities` does NOT work in user services (only system services)
- Must use `setcap` on the Python binary instead
- `NoNewPrivileges=true` prevents file capabilities from working - must be disabled
- Capabilities must be set on actual binary, not symlinks: `setcap ... $(readlink -f python3)`
#### Tasks:
- ✅ Created systemd/sensorpajen.service
- ✅ Created systemd/README.md with full documentation
- ✅ Service management and troubleshooting guides included
- ✅ Tested and verified working on Raspberry Pi
---
### Phase 7: Testing & Validation ✅ DONE (2025-12-27)
**Goal**: Verify new service works before removing legacy
**Notes**:
- Service tested and running successfully
- Legacy cron/tmux system stopped
- All sensors reporting correctly via systemd service
#### Tasks:
- ✅ Stopped legacy cron/tmux processes
- ✅ Started new systemd service
- ✅ Monitored logs - no errors
- ✅ Verified all 8 sensors reporting
- ✅ Confirmed MQTT publishing working
- ✅ Tested service restart and auto-recovery
---
### Phase 8: APT Package Creation ✓ TODO
**Goal**: Create Debian package for easy installation on Raspberry Pi
#### Tasks:
1. Create debian/ directory structure:
```bash
mkdir -p debian
```
2. Create `debian/control`:
``APT package installation instructions
- Development installation instructions
- Configuration guide (relative paths)
- Service management commands
- Troubleshooting section
- Remove DHT11 references
- Remove pirate_audio references
3. Create INSTALL.md:
- APT package installation steps
- Manual installation steps
- Configuration examples
- First-time setup guide
- Raspberry Pi specific instructionsds}, ${misc:Depends},
python3-bluepy,
python3-paho-mqtt,
bluetooth,
bluez
Description: Bluetooth temperature sensor monitor
Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
and publishes data to MQTT broker.
```
3. Create `debian/rules`:
```makefile
#!/usr/bin/make -f
%:
dh $@ --with python3 --buildsystem=pybuild
override_dh_auto_install:
pytOption 1: APT Package (Recommended for Raspberry Pi)
1. Download and install the .deb package:
```bash
sudo dpkg -i sensorpajen_1.0.0_all.deb
sudo apt-get install -f # Fix any dependencies
```
2. Configure:
```bash
mkdir -p ~/sensorpajen/config
cp /usr/share/doc/sensorpajen/examples/sensorpajen.env.example ~/sensorpajen/config/sensorpajen.env
cp /usr/share/doc/sensorpajen/examples/sensors.json.example ~/sensorpajen/config/sensors.json
# Edit both files
nano ~/sensorpajen/config/sensorpajen.env
nano ~/sensorpajen/config/sensors.json
chmod 600 ~/sensorpajen/config/sensorpajen.env
```
3. Enable and start service:
```bash
systemctl --user enable sensorpajen
systemctl --user start sensorpajen
```
### Option 2: Development Installation
1. Clone Repository
```bash
git clone <repo> ~/sensorpajen
cd ~/sensorpajen
```
2. Create Virtual Environment
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
### Relative Paths (For Portability)
- **Project root**: `~/sensorpajen/` (or wherever you clone/install)
- **Application config**: `~/sensorpajen/config/`
- **Environment file**: `~/sensorpajen/config/sensorpajen.env` (0600)
- **Sensor mapping**: `~/sensorpajen/config/sensors.json` (0644)
- **Service file**: `~/.config/systemd/user/sensorpajen.service`
### Advantages of Relative Paths
- Works on any system (development, production, multiple Raspberry Pis)
- Easy to backup/restore entire directory
- No hardcoded paths in code
- Simple to deploy via git pull or package installation
- User service runs without sudo
### APT Package Installation
When installed via .deb package:
- **Python package**: `/usr/lib/python3/dist-packages/sensorpajen/`
- **Service file**: `/lib/systemd/user/sensorpajen.service`
- **Config templates**: `/usr/share/doc/sensorpajen/examples/`
- **User config**: `~/sensorpajen/config/` (created by user)sensorpajen
```
5. Verify
```bash
systemctl --user status sensorpajen
journalctl --user -u sensorpajen -f
```uetooth access
if [ "$1" = "configure" ]; then
PYTHON_PATH=$(readlink -f /usr/bin/python3)
setcap 'cap_net_raw,cap_net_admin+eip' "$PYTHON_PATH" || true
fi
#DEBHELPER#
```
7. Create `debian/README.Debian`:
- Installation instructions
- Configuration guide
- Service management
8. Build the package:
```bash
dpkg-buildpackage -us -uc -b
```
9. Test installation on Raspberry Pi:
```bash
sudo dpkg -i ../sensorpajen_1.0.0_all.deb
sudo apt-get install -f # Fix dependencies if needed
```
10. Create installation documentation:
- Package installation instructions
- Configuration setup after installation
- Service enablement
---
### Phase 9: Cleanup & Documentation ✅ DONE (2025-12-27)
**Goal**: Remove legacy code and finalize documentation
**Notes**:
- Legacy cron/tmux scripts removed
- Documentation focused on practical usage
- INSTALL.md created for sysadmins
#### Tasks:
- ✅ Deleted legacy/ folder (old cron/tmux scripts)
- ✅ Created INSTALL.md with concise installation guide
- ✅ Updated README.md troubleshooting section
- ✅ Documentation assumes sysadmin familiarity
---
## Migration Complete! 🎉
All phases completed. The system has been successfully migrated from a legacy cron/tmux-based system to a modern systemd service with:
- ✅ Python package structure
- ✅ Environment-based configuration (no .ini files)
- ✅ Systemd user service with auto-restart
- ✅ Automatic sensor discovery with approval workflow
- ✅ Configuration auto-reload (no restart needed)
- ✅ ntfy notifications for new sensors
- ✅ Comprehensive documentation
**Version**: 2.0.0-dev
**Status**: Production-ready
```markdown
## Installation
### 1. Clone Repository
git clone <repo> /home/fredrik/dev/sensorpajen
cd /home/fredrik/dev/sensorpajen
### 2. Create Virtual Environment
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
### 3. Configure
mkdir -p ~/.config/sensorpajen
cp systemd/sensorpajen.env.example ~/.config/sensorpajen/sensorpajen.env
# Edit configuration
nano ~/.config/sensorpajen/sensorpajen.env
chmod 600 ~/.config/sensorpajen/sensorpajen.env
### 4. Convert Sensor Configuration
# Create sensors.json from your sensor list
### 5. Install Service
cp systemd/sensorpajen.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable sensorpajen
systemctl --user start sensorpajen
### 6. Verify
systemctl --user status sensorpajen
journalctl --user -u sensorpajen -f
```
5. Add troubleshooting section:
- Bluetooth permission issues
- MQTT connection problems
- Service won't start
- Log locations
---
## Configuration File Locations (Linux Best Practices)
### User Service Configuration
- **Service files**: `~/.config/systemd/user/`
- **Application config**: `~/.config/sensorpajen/`
- **Environment file**: `~/.config/sensorpajen/sensorpajen.env` (0600)
- **Sensor mapping**: `~/.config/sensorpajen/sensors.json` (0644)
### System Service (Alternative - Not Recommended)
If running as system service (not user service):
- **Service file**: `/etc/systemd/system/sensorpajen.service`
- **Config directory**: `/etc/sensorpajen/`
- **Environment file**: `/etc/sensorpajen/sensorpajen.env` (0600)
**Recommendation**: Use user service (current approach) since:
- No sudo required for service management
- Easier permission management
- Better security isolation
- Simpler Bluetooth access
---
## Success Criteria
The migration is complete when:
- ✅ Service starts automatically on boot
- ✅ All 8 Bluetooth sensors are being read
- ✅ MQTT messages are published correctly
- ✅ Service recovers automatically from crashes
- ✅ No hardcoded credentials in code
- ✅ Logs are visible via journalctl
- ✅ DHT11 functionality completely removed
- ✅ Legacy scripts removed
- ✅ Documentation is complete and accurate
- ✅ Service runs as user (not root)
- ✅ Virtual environment is working
---
## Rollback Plan
If issues arise during migration:
1. Stop new service:
```bash
systemctl --user stop sensorpajen
systemctl --user disable sensorpajen
```
2. Restore legacy scripts from legacy/ folder:
```bash
cp legacy/* .
```
3. Restore cron jobs:
```bash
crontab -e
# Uncomment:
# @reboot /home/fredrik/dev/sensorpajen/sensorer.sh
```
4. Reboot or manually start tmux session
---
## Future Enhancements
After successful migration, consider:
- [ ] Add Prometheus metrics endpoint
- [ ] Add systemd watchdog support
- [ ] Implement graceful sensor failure handling
- [ ] Add MQTT TLS support
- [ ] Create web dashboard for sensor status
- [ ] Add sensor calibration configuration
- [ ] Implement sensor auto-discovery
- [ ] Add health check endpoint
---
## Notes
- Keep legacy scripts during migration for safety
- Test thoroughly before removing cron jobs
- Monitor for at least 1-2 weeks before final cleanup
- Document any issues encountered during migration
- Take notes of actual MAC addresses and sensor names during conversion
---
## References
- systemd user services: `man systemd.service`
- XDG Base Directory: `~/.config/` for user configuration
- Bluetooth capabilities: `man capabilities`
- journalctl: `man journalctl`
- Python logging: https://docs.python.org/3/library/logging.html
## Completed Phases
-**Phase 0: Preparation & Cleanup** (2025-12-27)
-**Phase 0.1: Testing Infrastructure** (2025-12-29)
-**Phase 1: Modern TUI Management & Data Persistence** (2025-12-29)
-**Release: v3 Debian package** (2025-12-29)

271
TASKS.md
View File

@@ -1,271 +0,0 @@
# Tasks
## Task: Add Auto-Discovery and Approval Flow for Sensors
### Problem Statement
Adding new sensors currently requires manually editing `sensors.json`, which is error-prone and inconvenient.
The system should automatically detect new sensors and provide a controlled way for users to approve or ignore them.
---
## Goal
Implement **automatic sensor discovery** with a **user approval workflow** that:
* Detects new sensors automatically
* Notifies the user when new sensors are discovered
* Allows the user to approve or ignore sensors via a script
* Automatically updates `sensors.json` for approved sensors
* Restarts the service after configuration changes
---
## Scope
### In Scope
* Sensor auto-discovery
* Tracking newly discovered sensors
* Notification via `ntfy`
* Interactive user script for approving/ignoring sensors
* Updating `sensors.json`
* Restarting the service via systemd
### Out of Scope
* Web UI
* Authentication mechanisms beyond existing system access
* Changes to sensor hardware or firmware
* Long-term sensor management (removal, editing, etc.)
---
## Functional Requirements
### 1. Sensor Auto-Discovery
* The service must detect sensors that are not present in `sensors.json`
* Each newly discovered sensor must have a stable unique identifier
* Discovered-but-unapproved sensors must **not** be added automatically
---
### 2. Discovered Sensor Storage
* Newly discovered sensors must be stored in `config/discovered_sensors.json`
* Stored data must include:
* `mac` - MAC address (unique identifier)
* `name` - Advertised device name (e.g., "ATC_1234AB")
* `rssi` - Signal strength in dBm
* `first_seen` - ISO timestamp of first discovery
* `last_seen` - ISO timestamp of most recent advertisement
* `sample_reading` - One example reading with temperature, humidity, battery data
* `status` - One of: "pending", "approved", "ignored"
* `ignored_at` - ISO timestamp when ignored (if status is "ignored")
* `ignore_reason` - Optional user-provided reason for ignoring
* Approved sensors must have their status updated to "approved"
* Ignored sensors must remain in the file with status "ignored"
---
### 3. Notification via ntfy
* When a new sensor is discovered:
* Send a notification to the configured `ntfy` topic via curl
* Include at least:
* Sensor MAC address
* Sensor name
* Last seen timestamp
* Instruction that user action is required
* Configuration (in `config/sensorpajen.env`):
* `NTFY_ENABLED` - true/false to enable/disable notifications
* `NTFY_URL` - ntfy server URL (e.g., "https://ntfy.sh")
* `NTFY_TOPIC` - Topic to publish to
* `NTFY_TOKEN` - Authentication token (sent in header)
* ntfy is optional - system must work without it:
* If `NTFY_ENABLED=false`, skip notifications
* If ntfy is unreachable, log error and continue
* Discovery and approval must work even if ntfy fails
* The user must only be notified once per discovered sensor
---
### 4. User Approval Script
Provide a CLI command `sensorpajen approve-sensors` that:
* Lists all sensors with status "pending" or "ignored"
* For each sensor, displays:
* MAC address
* Advertised name (e.g., "ATC_1234AB")
* Last seen timestamp
* Sample reading (temperature, humidity, battery)
* Current status (pending/ignored)
* For each sensor, allows the user to:
* Approve the sensor (add to `sensors.json`)
* Ignore the sensor (mark as ignored)
* Skip (leave as pending for later)
* If approving:
* Prompt for a sensor name (required, human-readable)
* Pre-fill comment field with extended metadata (MAC, device name, last seen, sample reading)
* Allow user to edit or keep the pre-filled comment (optional)
* If ignoring:
* Prompt for optional reason
* Update status to "ignored" with timestamp
* Interactive mode only (no batch/automated approval)
---
### 5. Updating sensors.json
* When a sensor is approved:
* Add it to `sensors.json` (only if MAC doesn't already exist)
* Include:
* `mac` - MAC address from discovery
* `name` - User-provided human-readable name
* `comment` - User-edited comment (pre-filled with metadata)
* The file must remain valid JSON
* Existing sensors must not be modified
* If MAC already exists in `sensors.json`, skip adding (renaming is done manually in the file)
* Update status to "approved" in `discovered_sensors.json`
---
### 6. Configuration Reload
* The service must automatically reload `sensors.json` every 15 minutes
* No service restart required after approval
* If `sensors.json` is modified:
* Load new sensor list
* Start monitoring newly added sensors
* Continue monitoring existing sensors without interruption
* Log configuration reload events
---
## Non-Functional Requirements
* Must be safe to run on a Raspberry Pi
* Must not require a GUI
* Must fail gracefully if:
* `ntfy` is unreachable
* The user aborts the approval script
* Logging must clearly indicate:
* Discovery events
* Notifications sent
* Approval or ignore decisions
---
## Acceptance Criteria
* A new sensor is automatically detected and added to `discovered_sensors.json` with status "pending"
* Extended metadata (MAC, name, RSSI, timestamps, sample reading) is stored
* A notification is sent via `ntfy` when a sensor is discovered (if enabled)
* The approval CLI command (`sensorpajen approve-sensors`) lists pending and ignored sensors
* The CLI displays MAC, name, last seen, and sample reading for each sensor
* The user can approve a sensor with a custom name
* The comment field is pre-filled with metadata and user can edit it
* The user can ignore a sensor with an optional reason
* Previously ignored sensors can be approved in a later CLI run
* Approved sensors appear correctly in `sensors.json` (mac + name + comment only)
* Sensors already in `sensors.json` are not added again (no duplicates)
* The service automatically reloads `sensors.json` every 15 minutes
* New sensors are monitored without service restart
* Ignored sensors are stored with `ignored_at` timestamp and optional `ignore_reason`
* ntfy failures do not prevent discovery or approval workflow
---
## Notes for Implementation
* Prefer environment-based configuration (no `.ini` files)
* Keep the discovery logic separate from user interaction logic
* Avoid race conditions between discovery and approval
* Assume multiple sensors may be discovered before user action
* Use MAC address as unique identifier for sensors
* ntfy notification format: `curl -H "Authorization: Bearer $NTFY_TOKEN" -d "message" $NTFY_URL/$NTFY_TOPIC`
* Config reload: Use a timer thread that checks file mtime or reloads every 15 minutes
* Pre-filled comment example: `"MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:30:00, Temp: 21.5°C, Humidity: 45%, Battery: 87%"`
---
## Implementation Details
### File Locations
* Discovered sensors: `config/discovered_sensors.json`
* Known sensors: `config/sensors.json` (existing)
* Configuration: `config/sensorpajen.env` (add ntfy settings)
### New CLI Command
* Entry point: `sensorpajen approve-sensors`
* Add to `pyproject.toml` under `[project.scripts]`
### Configuration Variables (add to sensorpajen.env)
```bash
# ntfy notifications (optional)
NTFY_ENABLED=true
NTFY_URL=https://ntfy.sh
NTFY_TOPIC=sensorpajen
NTFY_TOKEN=tk_xxxxxxxxxxxxx
# Config reload interval (seconds)
CONFIG_RELOAD_INTERVAL=900 # 15 minutes
```
### discovered_sensors.json Structure
```json
[
{
"mac": "A4:C1:38:12:34:56",
"name": "ATC_1234AB",
"rssi": -65,
"first_seen": "2025-12-27T14:30:15",
"last_seen": "2025-12-27T14:35:42",
"sample_reading": {
"temperature": 21.5,
"humidity": 45,
"battery_percent": 87,
"battery_voltage": 2950
},
"status": "pending"
},
{
"mac": "A4:C1:38:AB:CD:EF",
"name": "ATC_ABCDEF",
"rssi": -72,
"first_seen": "2025-12-27T15:00:00",
"last_seen": "2025-12-27T15:10:00",
"sample_reading": {
"temperature": 19.8,
"humidity": 52,
"battery_percent": 65,
"battery_voltage": 2800
},
"status": "ignored",
"ignored_at": "2025-12-27T15:15:00",
"ignore_reason": "Test sensor, not needed"
}
]
```
### sensors.json Entry (after approval)
```json
{
"mac": "A4:C1:38:12:34:56",
"name": "Living Room",
"comment": "MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:35:42, Temp: 21.5°C, Humidity: 45%, Battery: 87%"
}
```
---
If you want, I can also:
* Split this into **multiple smaller tasks**
* Add a **definition of done** section
* Provide a **suggested file/module structure**
* Write a **follow-up roadmap entry** for sensor management
Just tell me how you want to evolve it next.

24
Tasks.md Normal file
View File

@@ -0,0 +1,24 @@
# Tasks
## Release: v3.0.0 ✅ DONE (2025-12-29)
**Goal**: Publish a v3 release and Debian package suitable for upgrades.
### Completed:
- ✅ Bump versions to 3.0.0 (Python + Debian changelog)
- ✅ Ensure Debian package includes the TUI sources
- ✅ Build `sensorpajen_3.0.0_all.deb`
## Task: TUI Enhancements (Phase 2)
**Goal**: Add live data, global config, and dashboard.
### Sub-tasks:
- [ ] **Live Data Integration**:
- [ ] Implement a message bus or shared state to feed live BLE readings into the TUI.
- [ ] Create a "Live Dashboard" view with real-time gauges/sparklines.
- [ ] **Global Configuration**:
- [ ] Create a form-based view to edit `sensorpajen.env` values.
- [ ] **System Dashboard**:
- [ ] Add system stats (CPU, Temp, Memory) relevant for Raspberry Pi.
- [ ] Add service control buttons (Restart, Stop).

View File

@@ -1 +1 @@
2.0.0-dev
3.0.0

View File

@@ -5,15 +5,22 @@ MQTT_USER=hasse
MQTT_PASSWORD=casablanca
MQTT_CLIENT_ID=mibridge
# Sensor Configuration (relative to project root)
SENSOR_CONFIG_FILE=config/sensors.json
DISCOVERED_SENSORS_FILE=config/discovered_sensors.json
# Sensor Configuration
# For system installation (/opt/sensorpajen): Use absolute paths
# SENSOR_CONFIG_FILE=/etc/sensorpajen/sensors.json
# DISCOVERED_SENSORS_FILE=/etc/sensorpajen/discovered_sensors.json
#
# For development installation: Use relative paths (from project root)
# SENSOR_CONFIG_FILE=config/sensors.json
# DISCOVERED_SENSORS_FILE=config/discovered_sensors.json
#
# If not set, defaults will be used based on installation type
# Application Settings
WATCHDOG_TIMEOUT=5
ENABLE_BATTERY=true
LOG_LEVEL=INFO
CONFIG_RELOAD_INTERVAL=900 # 15 minutes in seconds
CONFIG_RELOAD_INTERVAL=900
# ntfy Notifications (optional)
NTFY_ENABLED=false

364
debian/README.md vendored Normal file
View File

@@ -0,0 +1,364 @@
# Debian Package Build Guide
This directory contains the Debian packaging files for **sensorpajen**, a Bluetooth temperature sensor monitor for Raspberry Pi.
## Overview
The Debian package installs sensorpajen as a **system-wide service** with:
- Installation to `/opt/sensorpajen/`
- Configuration in `/etc/sensorpajen/`
- Dedicated `sensorpajen` system user
- Systemd service integration
- Automatic Python virtual environment setup
- Bluetooth capability configuration
## Prerequisites
### Required Packages
```bash
sudo apt install \
debhelper \
dpkg-dev \
python3 \
python3-venv \
python3-pip
```
### Optional (for verification)
```bash
sudo apt install lintian
```
## Quick Start
### Automated Build and Verification
```bash
# From project root
./scripts/verify-deb.sh
```
This script will:
1. Check for required tools
2. Build the package
3. Show package contents
4. Run lintian checks
5. Display installation instructions
### Manual Build
```bash
# From project root
dpkg-buildpackage -us -uc -b
```
The `.deb` file will be created in the parent directory:
```bash
ls -lh ../sensorpajen_*.deb
```
## Build Output
```
../sensorpajen_3.0.0_all.deb # Installable package
../sensorpajen_3.0.0_armhf.build # Build log
../sensorpajen_3.0.0_armhf.buildinfo # Build metadata
../sensorpajen_3.0.0_armhf.changes # Changes file
```
## Package Verification
### Check Package Contents
```bash
dpkg-deb -c ../sensorpajen_*.deb
```
### Check Package Metadata
```bash
dpkg-deb -I ../sensorpajen_*.deb
```
### Run Lintian
```bash
lintian ../sensorpajen_*.deb
```
**Note**: Warnings are acceptable. Focus on fixing errors.
## Installation
### On Raspberry Pi
```bash
# Copy package to Pi
scp ../sensorpajen_*.deb pi@raspberrypi:~/
# SSH to Pi and install
ssh pi@raspberrypi
sudo apt install ./sensorpajen_*.deb
```
### Local Testing (Not Recommended)
Installing on your development machine will modify `/opt` and `/etc`:
```bash
sudo apt install ../sensorpajen_*.deb
```
**Warning**: This will create system directories and a system user on your dev machine.
## Post-Installation Configuration
After installing the package:
```bash
# 1. Edit MQTT credentials
sudo nano /etc/sensorpajen/sensorpajen.env
# 2. Configure sensors
sudo nano /etc/sensorpajen/sensors.json
# 3. Start the service
sudo systemctl start sensorpajen
# 4. Check status
sudo systemctl status sensorpajen
# 5. View logs
sudo journalctl -u sensorpajen -f
```
## Running the TUI
The package installs a `sensorpajen-tui` command in `/usr/bin/`.
```bash
sudo sensorpajen-tui
```
Internally this runs the application from `/opt/sensorpajen/venv/`.
## Package Structure
### Installed Files
| Source | Destination |
|--------|-------------|
| `src/sensorpajen/*.py` | `/opt/sensorpajen/src/sensorpajen/` |
| `src/sensorpajen/tui/*.py` | `/opt/sensorpajen/src/sensorpajen/tui/` |
| `scripts/approve-sensors.sh` | `/opt/sensorpajen/scripts/` |
| `pyproject.toml` | `/opt/sensorpajen/` |
| `README.md`, `INSTALL.md`, `ROADMAP.md` | `/usr/share/doc/sensorpajen/` |
| `config/*.example` | `/usr/share/doc/sensorpajen/examples/` |
| `debian/sensorpajen.service` | `/etc/systemd/system/` |
| *(created in postinst)* | `/opt/sensorpajen/venv/` |
| *(created in postinst)* | `/etc/sensorpajen/` |
### Configuration Files
- **Active Config**: `/etc/sensorpajen/sensorpajen.env` (credentials)
- **Active Config**: `/etc/sensorpajen/sensors.json` (sensor list)
- **Discovery Data**: `/etc/sensorpajen/discovered_sensors.json`
- **Examples**: `/usr/share/doc/sensorpajen/examples/*.example`
## Maintainer Scripts
### postinst (Post-Installation)
Runs after package installation:
1. Creates `sensorpajen` system user (if doesn't exist)
2. Creates `/etc/sensorpajen/` directory
3. Copies example configs to `/etc/sensorpajen/` (if missing)
4. Creates Python virtual environment in `/opt/sensorpajen/venv/`
5. Installs Python dependencies via pip
6. Sets Bluetooth capabilities on Python executable
7. Installs systemd service file
8. Enables service (but doesn't start until configured)
### prerm (Pre-Removal)
Runs before package removal:
1. Stops the sensorpajen service
2. Disables the service (on remove, not upgrade)
### postrm (Post-Removal)
Runs after package removal:
1. Removes systemd service file
2. Reloads systemd daemon
3. **Preserves** configuration in `/etc/sensorpajen/`
4. **Preserves** `sensorpajen` user
**Note**: Configuration and user are intentionally preserved to prevent data loss.
## Upgrade Behavior
When upgrading to a new version:
```bash
sudo apt install ./sensorpajen_2.1.0_all.deb
```
- ✅ Service is stopped during upgrade
- ✅ Old files are replaced
- ✅ Configuration in `/etc/sensorpajen/` is **preserved**
- ✅ Python dependencies are updated
- ✅ Service is restarted after upgrade
- ✅ Example files in `/usr/share/doc/` are updated
## Removal Behavior
### Remove (Keep Config)
```bash
sudo apt remove sensorpajen
```
- Service stopped and disabled
- Application files removed from `/opt/sensorpajen/`
- Configuration **preserved** in `/etc/sensorpajen/`
- User **preserved**
### Purge (Still Keeps Config)
```bash
sudo apt purge sensorpajen
```
- Same as remove
- Configuration still **preserved** (by design, for safety)
- User still **preserved**
### Complete Removal
To completely remove everything:
```bash
sudo apt purge sensorpajen
sudo rm -rf /etc/sensorpajen
sudo userdel sensorpajen
```
## Troubleshooting
### Build Fails: "debhelper: command not found"
```bash
sudo apt install debhelper
```
### Build Fails: "dh_python3: command not found"
```bash
sudo apt install dh-python
```
### Lintian Warnings About Permissions
The postinst script runs as root and sets file permissions. This is expected and safe.
### Package Won't Install: Dependency Issues
```bash
# Fix missing dependencies
sudo apt install -f
```
### Service Won't Start After Install
Check if configuration has been edited:
```bash
sudo journalctl -u sensorpajen -n 50
```
Common issues:
- MQTT_HOST still has example value
- sensors.json is empty
- Bluetooth adapter not available
### Bluetooth Capability Not Set
```bash
# Manually set capability
sudo setcap cap_net_raw,cap_net_admin+eip $(readlink -f /opt/sensorpajen/venv/bin/python3)
# Verify
getcap $(readlink -f /opt/sensorpajen/venv/bin/python3)
```
## Development Workflow
### Making Changes
1. Edit source code in `src/sensorpajen/`
2. Update version in `pyproject.toml`
3. Update `debian/changelog` with new entry
4. Rebuild package: `./scripts/verify-deb.sh`
5. Test on Raspberry Pi
### Version Numbering
- Development: `2.0.0-dev`
- Release: `2.0.0`
- Patch: `2.0.1`
Update in both:
- `pyproject.toml` (line 6: `version = "..."`)
- `debian/changelog` (first line)
### Testing on Pi
```bash
# Build
./scripts/verify-deb.sh
# Copy to Pi
scp ../sensorpajen_*.deb pi@raspberrypi:~/
# Install on Pi
ssh pi@raspberrypi
sudo systemctl stop sensorpajen # If upgrading
sudo apt install ./sensorpajen_*.deb
sudo systemctl status sensorpajen
```
## Package Metadata
**Package Name**: sensorpajen
**Section**: misc
**Priority**: optional
**Architecture**: all (pure Python)
**Maintainer**: Fredrik <fredrik@wahlberg.se>
**Depends**: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin
**Recommends**: mosquitto-clients
## Additional Resources
- **TASKS.md**: Detailed implementation notes
- **ROADMAP.md**: Phase 8 section for APT package creation
- **INSTALL.md**: User installation guide
- **systemd/README.md**: Service management guide
## Support
For issues or questions:
1. Check `sudo journalctl -u sensorpajen -n 100`
2. Verify configuration files in `/etc/sensorpajen/`
3. Check Bluetooth adapter: `hciconfig`
4. Test MQTT connection: `mosquitto_pub -h <host> -t test -m "test"`
---
**Last Updated**: December 27, 2025
**Package Version**: 2.0.0-dev

28
debian/changelog vendored Normal file
View File

@@ -0,0 +1,28 @@
sensorpajen (3.0.0) stable; urgency=medium
* Production release v3.0.0
* Textual TUI for sensor approval/management
* Per-sensor comments persisted in sensors.json
* Improved safety UX (delete confirmation, details view)
-- Fredrik <fredrik@wahlberg.se> Mon, 29 Dec 2025 12:00:00 +0100
sensorpajen (2.0.0) stable; urgency=medium
* Production release v2.0.0
* Modernized service architecture with systemd
* Automatic sensor discovery and approval workflow
* Fixed state directory for discovered_sensors.json
* Improved documentation with installation guide
* Bytecode cleanup in postinst for clean installs
-- Fredrik <fredrik@wahlberg.se> Sun, 28 Dec 2025 10:56:00 +0100
sensorpajen (2.0.0-dev) unstable; urgency=medium
* Initial Debian package release
* Modernized service architecture with systemd
* Automatic sensor discovery and approval workflow
-- Fredrik <fredrik@wahlberg.se> Sun, 28 Dec 2025 09:00:00 +0100

22
debian/control vendored Normal file
View File

@@ -0,0 +1,22 @@
Source: sensorpajen
Section: misc
Priority: optional
Maintainer: Fredrik <fredrik@wahlberg.se>
Build-Depends: debhelper-compat (= 13)
Standards-Version: 4.5.0
Homepage: https://github.com/yourusername/sensorpajen
Package: sensorpajen
Architecture: all
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin, ${misc:Depends}
Recommends: mosquitto-clients
Description: Raspberry Pi Bluetooth temperature sensor monitor
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
and publishes readings to MQTT broker. Supports ATC firmware with automatic
sensor discovery and approval workflow.
.
Features:
- Automatic sensor discovery
- MQTT publishing
- Systemd service integration
- User approval workflow for new sensors

10
debian/install vendored Normal file
View File

@@ -0,0 +1,10 @@
src/sensorpajen/*.py opt/sensorpajen/src/sensorpajen/
src/sensorpajen/tui/*.py opt/sensorpajen/src/sensorpajen/tui/
scripts/approve-sensors.sh opt/sensorpajen/scripts/
debian/sensorpajen-tui usr/bin/
pyproject.toml opt/sensorpajen/
requirements.txt opt/sensorpajen/
readme.md usr/share/doc/sensorpajen/
INSTALL.md usr/share/doc/sensorpajen/
ROADMAP.md usr/share/doc/sensorpajen/
config/*.example usr/share/doc/sensorpajen/examples/

161
debian/postinst vendored Executable file
View File

@@ -0,0 +1,161 @@
#!/bin/bash
set -e
case "$1" in
configure)
# Create sensorpajen system user if it doesn't exist
if ! getent passwd sensorpajen > /dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
echo "Created system user: sensorpajen"
fi
# Create config directory with proper permissions
mkdir -p /etc/sensorpajen
chown sensorpajen:sensorpajen /etc/sensorpajen
chmod 750 /etc/sensorpajen
# Create state directory with proper permissions (writable at runtime)
mkdir -p /var/lib/sensorpajen
chown sensorpajen:sensorpajen /var/lib/sensorpajen
chmod 750 /var/lib/sensorpajen
# Copy example configs to /etc/sensorpajen if they don't exist
for sample in sensorpajen.env.example sensors.json.example; do
source_file="/usr/share/doc/sensorpajen/examples/$sample"
target_file="/etc/sensorpajen/${sample%.example}"
if [ -f "$source_file" ] && [ ! -f "$target_file" ]; then
cp "$source_file" "$target_file"
chown sensorpajen:sensorpajen "$target_file"
# Set restrictive permissions on env file (contains credentials)
if [ "$sample" = "sensorpajen.env.example" ]; then
chmod 600 "$target_file"
echo "Created $target_file (edit this file with your MQTT credentials)"
else
chmod 640 "$target_file"
echo "Created $target_file"
fi
fi
done
# Create virtual environment in /opt/sensorpajen
cd /opt/sensorpajen
if [ ! -d "venv" ]; then
echo "Creating Python virtual environment..."
python3 -m venv venv
venv/bin/pip install --upgrade pip setuptools wheel
fi
# Install Python dependencies from requirements.txt
echo "Installing Python dependencies..."
if [ -f "/opt/sensorpajen/requirements.txt" ]; then
venv/bin/pip install -r /opt/sensorpajen/requirements.txt
else
echo "Warning: requirements.txt not found, installing bluepy and paho-mqtt directly"
venv/bin/pip install bluepy paho-mqtt pybluez
fi
if [ $? -ne 0 ]; then
echo "Error: Failed to install dependencies"
exit 1
fi
# Install sensorpajen package itself
echo "Installing sensorpajen application..."
cd /opt/sensorpajen
# Clean up any stale bytecode before building wheel
find . -name "*.pyc" -delete
find . -name "__pycache__" -type d -delete
venv/bin/pip install --no-deps . || {
echo "Error: Failed to install sensorpajen package"
exit 1
}
cd /
# Set ownership of application directory BEFORE setting capabilities
chown -R sensorpajen:sensorpajen /opt/sensorpajen
# Set Bluetooth capabilities on Python executable (after ownership change)
PYTHON_PATH=$(readlink -f /opt/sensorpajen/venv/bin/python3)
if command -v setcap >/dev/null 2>&1; then
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || {
echo "Warning: setcap failed. You may need to run Bluetooth operations as root."
echo "Try: sudo setcap cap_net_raw,cap_net_admin+eip $PYTHON_PATH"
}
else
echo "Warning: setcap not found (install libcap2-bin package)"
fi
# v2 installed a unit into /etc/systemd/system/, which overrides packaged units
# and prevents upgrades from taking effect. If that file exists and is identical
# to the packaged unit, remove the override.
if [ -f /etc/systemd/system/sensorpajen.service ]; then
PACKAGED_UNIT=""
if [ -f /lib/systemd/system/sensorpajen.service ]; then
PACKAGED_UNIT="/lib/systemd/system/sensorpajen.service"
elif [ -f /usr/lib/systemd/system/sensorpajen.service ]; then
PACKAGED_UNIT="/usr/lib/systemd/system/sensorpajen.service"
fi
if [ -n "$PACKAGED_UNIT" ] && diff -q /etc/systemd/system/sensorpajen.service "$PACKAGED_UNIT" >/dev/null 2>&1; then
rm -f /etc/systemd/system/sensorpajen.service
echo "Removed redundant /etc override unit (upgrade-safe)"
fi
fi
# Reload systemd
systemctl daemon-reload
# Enable service (but don't start - needs configuration first)
systemctl enable sensorpajen.service || {
echo "Warning: Could not enable sensorpajen service"
}
# Check if configuration is ready
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
# Check if env file has been configured (not default values)
if grep -q "MQTT_HOST=192.168.0.114" /etc/sensorpajen/sensorpajen.env; then
echo ""
echo "======================================================================"
echo " Configuration needed!"
echo "======================================================================"
echo " Edit /etc/sensorpajen/sensorpajen.env with your MQTT settings"
echo " Edit /etc/sensorpajen/sensors.json with your sensor list"
echo " Then run: sudo systemctl start sensorpajen"
echo "======================================================================"
echo ""
else
# Configuration appears to be customized, restart service
systemctl restart sensorpajen.service && {
echo "Sensorpajen service started"
echo "View logs: sudo journalctl -u sensorpajen -f"
} || {
echo "Failed to start service. Check: sudo systemctl status sensorpajen"
}
fi
else
echo ""
echo "======================================================================"
echo " Sensorpajen installed successfully!"
echo "======================================================================"
echo " Next steps:"
echo " 1. Edit /etc/sensorpajen/sensorpajen.env"
echo " 2. Edit /etc/sensorpajen/sensors.json"
echo " 3. sudo systemctl start sensorpajen"
echo " 4. sudo journalctl -u sensorpajen -f"
echo "======================================================================"
echo ""
fi
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

41
debian/postrm vendored Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -e
case "$1" in
remove)
# Service removed but config and user preserved
echo "Sensorpajen removed. Configuration preserved in /etc/sensorpajen/"
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
# Remove systemd service file
rm -f /etc/systemd/system/sensorpajen.service
systemctl daemon-reload || true
;;
purge)
# Even on purge, we keep config by default (user can manually delete)
# This is safer as it prevents accidental data loss
echo "Configuration preserved in /etc/sensorpajen/"
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
echo "To remove user: sudo userdel sensorpajen"
# Remove systemd service file
rm -f /etc/systemd/system/sensorpajen.service
systemctl daemon-reload || true
# Note: We intentionally do NOT remove:
# - /etc/sensorpajen (contains user data)
# - sensorpajen user (may own other files/processes)
# User must remove these manually if desired
;;
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
*)
echo "postrm called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

27
debian/prerm vendored Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
case "$1" in
remove|upgrade|deconfigure)
# Stop service before removal or upgrade
if systemctl is-active --quiet sensorpajen.service 2>/dev/null; then
echo "Stopping sensorpajen service..."
systemctl stop sensorpajen.service || true
fi
# Disable service on removal (not upgrade)
if [ "$1" = "remove" ]; then
systemctl disable sensorpajen.service || true
fi
;;
failed-upgrade)
;;
*)
echo "prerm called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

20
debian/rules vendored Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_build:
# No build step needed for pure Python
override_dh_auto_install:
# Installation handled by debian/install file
dh_auto_install
override_dh_auto_clean:
# Clean build artifacts
rm -rf build/ dist/ *.egg-info
rm -rf src/*.egg-info
override_dh_builddeb:
# Use gzip compression for better compatibility
dh_builddeb -- -Zgzip

12
debian/sensorpajen-tui vendored Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
# Wrapper to run the installed TUI using the app's virtualenv.
# The venv is created/updated by the package postinst.
if [ -x /opt/sensorpajen/venv/bin/sensorpajen-tui ]; then
exec /opt/sensorpajen/venv/bin/sensorpajen-tui "$@"
fi
# Fallback (should normally not be needed)
exec /opt/sensorpajen/venv/bin/python -m sensorpajen.tui.app "$@"

1
debian/sensorpajen.debhelper.log vendored Normal file
View File

@@ -0,0 +1 @@
dh_builddeb

32
debian/sensorpajen.service vendored Normal file
View File

@@ -0,0 +1,32 @@
[Unit]
Description=Sensorpajen - Bluetooth Temperature Sensor Monitor
Documentation=https://github.com/yourusername/sensorpajen
After=network.target bluetooth.target
Wants=bluetooth.target
[Service]
Type=simple
User=sensorpajen
Group=sensorpajen
WorkingDirectory=/opt/sensorpajen
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
Restart=always
RestartSec=10
# Bluetooth capabilities require this to be false
NoNewPrivileges=false
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=sensorpajen
# Security hardening (where possible with Bluetooth requirements)
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/etc/sensorpajen /var/lib/sensorpajen
[Install]
WantedBy=multi-user.target

View File

@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
[project]
name = "sensorpajen"
version = "2.0.0-dev"
version = "3.0.0"
description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC"
readme = "README.md"
readme = "readme.md"
requires-python = ">=3.9"
license = {text = "MIT"}
authors = [
@@ -14,7 +14,7 @@ authors = [
]
keywords = ["bluetooth", "temperature", "sensor", "mqtt", "raspberry-pi"]
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
@@ -26,8 +26,10 @@ classifiers = [
]
dependencies = [
"pybluez",
"bluepy>=1.3.0",
"paho-mqtt>=1.6.0",
"textual>=0.40.0",
]
[project.optional-dependencies]
@@ -45,6 +47,7 @@ Repository = "https://github.com/yourusername/sensorpajen"
[project.scripts]
sensorpajen = "sensorpajen.main:main"
sensorpajen-approve-sensors = "sensorpajen.approve_sensors:main"
sensorpajen-tui = "sensorpajen.tui.app:main"
[tool.setuptools.packages.find]
where = ["src"]

173
readme.md
View File

@@ -22,56 +22,91 @@ Raspberry Pi service that monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature
## Installation
See [INSTALL.md](INSTALL.md) for complete installation instructions.
### System Installation (Debian Package - Recommended for Raspberry Pi)
### Quick Start
The easiest way to install on Raspberry Pi OS is using the pre-built Debian package:
```bash
# Clone repository
git clone <repo-url> ~/sensorpajen
cd ~/sensorpajen
# Download the latest release
wget https://gitea.wahlberg.se/api/v1/repos/fredrik/sensorpajen/releases/download/v3.0.0/sensorpajen_3.0.0_all.deb
# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -e .
# Install
sudo dpkg -i sensorpajen_3.0.0_all.deb
# Configure
cp config/sensorpajen.env.example config/sensorpajen.env
cp config/sensors.json.example config/sensors.json
nano config/sensorpajen.env # Edit MQTT settings
nano config/sensors.json # Edit sensor MAC addresses
sudo nano /etc/sensorpajen/sensorpajen.env # Edit MQTT settings
sudo systemctl restart sensorpajen
# Set Bluetooth capabilities
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f .venv/bin/python3)
# Install systemd service
mkdir -p ~/.config/systemd/user/
cp systemd/sensorpajen.service ~/.config/systemd/user/
systemctl --user daemon-reload
sudo loginctl enable-linger $USER
# Start service
systemctl --user enable sensorpajen
systemctl --user start sensorpajen
# Check status
systemctl --user status sensorpajen
journalctl --user -u sensorpajen -f
# View logs
sudo journalctl -u sensorpajen -f
```
The system package installs:
- Application in `/opt/sensorpajen/`
- Configuration in `/etc/sensorpajen/`
- Runtime state in `/var/lib/sensorpajen/`
- Systemd service (runs automatically)
### Development Installation
See [INSTALL.md](INSTALL.md) for complete development setup.
## Configuration
### Quick Setup
After installation, configure your MQTT broker and sensors:
```bash
# Edit MQTT settings
sudo nano /etc/sensorpajen/sensorpajen.env
# Restart service to apply changes
sudo systemctl restart sensorpajen
```
### Sensor Management (TUI)
The service automatically discovers nearby Bluetooth sensors. You can manage them using the built-in Text UI:
```bash
# Launch the management TUI
sudo sensorpajen-tui
```
The TUI allows you to:
- **Discovery**: View newly discovered sensors and **Approve** (add to monitoring) or **Ignore** them.
- **Configured**: View currently monitored sensors, **Edit** their names, or **Remove** them.
- **Ignored**: View ignored sensors and **Unignore** them if you change your mind.
**Keybindings:**
- `a`: Approve selected sensor
- `i`: Ignore selected sensor
- `e`: Edit sensor name and comment
- `v`: View details (MAC/name/comment)
- `u`: Unignore sensor
- `Delete`: Remove sensor from monitoring
- `r`: Refresh data
- `q`: Quit
When you approve a sensor, it's added to your configuration and the service automatically starts monitoring it.
### Legacy CLI Approval (Deprecated)
The recommended workflow is the TUI (`sensorpajen-tui`). A legacy CLI tool still exists:
```bash
sudo sensorpajen-approve-sensors
```
### MQTT Settings
Edit `config/sensorpajen.env`:
Edit `/etc/sensorpajen/sensorpajen.env`:
```bash
MQTT_HOST=192.168.1.10
MQTT_PORT=1883
MQTT_USERNAME=username
MQTT_USER=username
MQTT_PASSWORD=password
MQTT_CLIENT_ID=sensorpajen
MQTT_TOPIC_PREFIX=MiTemperature2
@@ -79,10 +114,11 @@ MQTT_TOPIC_PREFIX=MiTemperature2
### Sensors
Edit `config/sensors.json`:
Sensors are automatically managed via the approval workflow. You can also manually edit `/etc/sensorpajen/sensors.json`:
```json
[
{
"sensors": [
{
"mac": "A4:C1:38:12:34:56",
"name": "Living Room"
@@ -91,30 +127,40 @@ Edit `config/sensors.json`:
"mac": "A4:C1:38:AB:CD:EF",
"name": "Bedroom"
}
]
]
}
```
## Service Management
See [systemd/README.md](systemd/README.md) for detailed service management instructions.
### System Installation (Debian Package)
```bash
# Start/stop service
systemctl --user start sensorpajen
systemctl --user stop sensorpajen
sudo systemctl start sensorpajen
sudo systemctl stop sensorpajen
# Enable/disable autostart
systemctl --user enable sensorpajen
systemctl --user disable sensorpajen
sudo systemctl enable sensorpajen
sudo systemctl disable sensorpajen
# View status
systemctl --user status sensorpajen
sudo systemctl status sensorpajen
# View logs
journalctl --user -u sensorpajen -f
journalctl --user -u sensorpajen -n 100
# View logs (live)
sudo journalctl -u sensorpajen -f
# View last 50 log lines
sudo journalctl -u sensorpajen -n 50
# Uninstall
sudo dpkg -r sensorpajen
# Note: Configuration is preserved in /etc/sensorpajen/
# To remove config: sudo rm -rf /etc/sensorpajen/
```
### Development Installation
## Flashing New Thermometers
**Important**: Flash only one thermometer at a time!
@@ -158,27 +204,40 @@ sensorpajen/
## Troubleshooting
See [INSTALL.md](INSTALL.md#troubleshooting) for detailed troubleshooting steps.
### System Installation (Debian Package)
### Quick Checks
**Permission errors:**
**Service won't start:**
```bash
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f ~/sensorpajen/.venv/bin/python3)
systemctl --user restart sensorpajen
# Check what's wrong
sudo journalctl -u sensorpajen -n 50
# Check configuration is valid
sudo cat /etc/sensorpajen/sensorpajen.env
# Manually test the application
sudo /opt/sensorpajen/venv/bin/python -m sensorpajen.main
```
**Service status:**
**MQTT connection issues:**
```bash
systemctl --user status sensorpajen
journalctl --user -u sensorpajen -f
```
# Verify MQTT settings in the log
sudo journalctl -u sensorpajen | grep MQTT
**MQTT test:**
```bash
# Test MQTT connection manually
mosquitto_sub -h <MQTT_HOST> -u <USER> -P <PASSWORD> -t "MiTemperature2/#" -v
```
**Sensor not found:**
```bash
# Run the TUI to view/approve newly discovered sensors
sudo sensorpajen-tui
# Check recent logs
sudo journalctl -u sensorpajen -n 100
```
### Development Installation
## Development
```bash

View File

@@ -1,2 +1,3 @@
pybluez
bluepy
paho-mqtt

View File

@@ -1,23 +1,48 @@
#!/bin/bash
# Wrapper script for approve-sensors that sets minimal required env vars
# Wrapper script for approve-sensors that works in both dev and system mode
# Get script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
# Detect installation type
if [ -d "/opt/sensorpajen" ]; then
# System installation
PROJECT_ROOT="/opt/sensorpajen"
VENV_PATH="/opt/sensorpajen/venv"
# Set minimal required environment variables
export MQTT_HOST="${MQTT_HOST:-localhost}"
export MQTT_PORT="${MQTT_PORT:-1883}"
# Load config from system location
if [ -f "/etc/sensorpajen/sensorpajen.env" ]; then
set -a
source /etc/sensorpajen/sensorpajen.env
set +a
else
echo "Warning: /etc/sensorpajen/sensorpajen.env not found"
# Set minimal defaults
export MQTT_HOST="${MQTT_HOST:-localhost}"
export MQTT_PORT="${MQTT_PORT:-1883}"
fi
else
# Development installation
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
VENV_PATH="$PROJECT_ROOT/.venv"
# Load actual config if it exists (will override defaults)
if [ -f "$PROJECT_ROOT/config/sensorpajen.env" ]; then
# Set minimal required environment variables
export MQTT_HOST="${MQTT_HOST:-localhost}"
export MQTT_PORT="${MQTT_PORT:-1883}"
# Load actual config if it exists (will override defaults)
if [ -f "$PROJECT_ROOT/config/sensorpajen.env" ]; then
set -a
source "$PROJECT_ROOT/config/sensorpajen.env"
set +a
fi
fi
# Activate virtual environment
source "$PROJECT_ROOT/.venv/bin/activate"
if [ -f "$VENV_PATH/bin/activate" ]; then
source "$VENV_PATH/bin/activate"
else
echo "Error: Virtual environment not found at $VENV_PATH"
exit 1
fi
# Run the approve-sensors command
python -m sensorpajen.approve_sensors "$@"

206
scripts/dev-remote.sh Executable file
View File

@@ -0,0 +1,206 @@
#!/bin/bash
set -e
# Configuration
REMOTE_HOST="10.0.0.1"
REMOTE_USER="pi"
REMOTE_DIR="~/sensorpajen-dev"
REMOTE_VENV="$REMOTE_DIR/.venv"
# Colors
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
log() {
echo -e "${BLUE}[DEV-REMOTE]${NC} $1"
}
warn() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
error() {
echo -e "${RED}[ERROR]${NC} $1"
}
cleanup() {
log "Cleaning up..."
# Kill the background python process if we stored its PID
if [ ! -z "$BG_PID" ]; then
log "Stopping remote dev backend (PID: $BG_PID)..."
ssh -t $REMOTE_USER@$REMOTE_HOST "sudo kill $BG_PID 2>/dev/null || true"
fi
log "Restarting production service..."
ssh -t $REMOTE_USER@$REMOTE_HOST "sudo systemctl start sensorpajen"
log "Done."
}
# Trap cleanup on exit
trap cleanup EXIT
# Resolve script directory and project root
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# 1. Sync Code
log "Syncing code from $PROJECT_ROOT to $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR..."
rsync -avz --exclude '.venv' --exclude '__pycache__' --exclude '*.egg-info' \
--exclude '*.db' --exclude '*.db-*' --exclude '*.sqlite' --exclude '*.sqlite-*' \
"$PROJECT_ROOT/src" "$PROJECT_ROOT/scripts" "$PROJECT_ROOT/pyproject.toml" "$PROJECT_ROOT/config" \
"$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/"
# 2. Setup Remote Environment (Initial setup)
log "Ensuring remote environment is ready..."
ssh -t $REMOTE_USER@$REMOTE_HOST "
mkdir -p $REMOTE_DIR
cd $REMOTE_DIR
# Install system dependencies (always check)
echo 'Checking system dependencies...'
# Only run apt-get update if we need to install something to save time?
# Or just run it. Let's run install, it's safer.
if ! dpkg -s libbluetooth-dev >/dev/null 2>&1; then
echo 'Installing libbluetooth-dev...'
sudo apt-get update
sudo apt-get install -y libbluetooth-dev python3-dev
fi
# Create venv if missing
if [ ! -d .venv ]; then
echo 'Creating virtual environment...'
python3 -m venv .venv
fi
# Install/Update dependencies
echo 'Installing dependencies (forcing reinstall to ensure code update)...'
.venv/bin/pip install --upgrade pip
.venv/bin/pip install --force-reinstall --no-deps .
# Verify code sync
echo 'Verifying code sync...'
if grep -q "sensor_config=self.sensor_config" src/sensorpajen/main.py; then
echo "✅ main.py has latest changes."
else
echo "❌ main.py does NOT have latest changes! Rsync might have failed."
fi
# Ensure config exists (copy from system or examples if totally missing)
if [ ! -d config ]; then mkdir config; fi
# Rename DB to match default expectation in config.py (sensorpajen.db) if it was copied as discovered_sensors.db
# Blindly try to copy with sudo since we can't check existence in restricted dir
echo 'Copying config files from /etc/sensorpajen (blindly due to permissions)...'
# DB
# Only copy if they don't exist locally to preserve dev data
if [ ! -f config/sensorpajen.db ]; then
echo 'Copying database from system...'
# Try discovered_sensors.db first, then sensorpajen.db
if [ -f /etc/sensorpajen/discovered_sensors.db ]; then
sudo cp /etc/sensorpajen/discovered_sensors.db config/sensorpajen.db 2>/dev/null || true
elif [ -f /etc/sensorpajen/sensorpajen.db ]; then
sudo cp /etc/sensorpajen/sensorpajen.db config/sensorpajen.db 2>/dev/null || true
fi
# Ensure correct ownership immediately after copy
sudo chown $REMOTE_USER:$REMOTE_USER config/sensorpajen.db 2>/dev/null || true
else
echo 'Preserving existing database config/sensorpajen.db'
fi
# Configs
# Only copy if they don't exist locally to preserve dev changes
if [ ! -f config/sensors.json ]; then
echo 'Copying sensors.json from system...'
sudo cp /etc/sensorpajen/sensors.json config/sensors.json 2>/dev/null || true
else
echo 'Preserving existing config/sensors.json'
fi
if [ ! -f config/sensorpajen.env ]; then
echo 'Copying sensorpajen.env from system...'
sudo cp /etc/sensorpajen/sensorpajen.env config/sensorpajen.env 2>/dev/null || true
else
echo 'Preserving existing config/sensorpajen.env'
fi
# ALWAYS sanitize sensorpajen.env to ensure we don't use system paths
# and set explicit dev paths
if [ -f config/sensorpajen.env ]; then
echo 'Sanitizing and setting dev paths in config/sensorpajen.env...'
sudo sed -i '/^SENSOR_CONFIG_FILE/d' config/sensorpajen.env
sudo sed -i '/^DATABASE_FILE/d' config/sensorpajen.env
sudo sed -i '/^DISCOVERED_SENSORS_FILE/d' config/sensorpajen.env
# Add dev paths explicitly (use absolute paths since we're in ssh context)
echo "SENSOR_CONFIG_FILE=/home/$REMOTE_USER/sensorpajen-dev/config/sensors.json" | sudo tee -a config/sensorpajen.env > /dev/null
echo "DATABASE_FILE=/home/$REMOTE_USER/sensorpajen-dev/config/sensorpajen.db" | sudo tee -a config/sensorpajen.env > /dev/null
echo "DISCOVERED_SENSORS_FILE=/home/$REMOTE_USER/sensorpajen-dev/config/discovered_sensors.json" | sudo tee -a config/sensorpajen.env > /dev/null
fi
# Examples (if real config missing)
# We don't need to do anything here, the rsync already brought examples if they exist locally
# FIX OWNERSHIP recursively
echo 'Fixing permissions...'
sudo chown -R $REMOTE_USER:$REMOTE_USER $REMOTE_DIR
"
# 3. Stop Production Service
log "Stopping production service to free up Bluetooth..."
ssh -t $REMOTE_USER@$REMOTE_HOST "sudo systemctl stop sensorpajen"
# 4. Run Dev Backend in Background
log "Starting DEV backend on remote..."
# Grant capabilities to the venv python
ssh -t $REMOTE_USER@$REMOTE_HOST "
cd $REMOTE_DIR
PYTHON_BIN=\$(readlink -f .venv/bin/python3)
# Install libcap2-bin if missing
if ! command -v setcap &> /dev/null; then
sudo apt-get install -y libcap2-bin
fi
sudo setcap cap_net_raw,cap_net_admin+eip \"\$PYTHON_BIN\"
"
# Run backend as USER (not sudo) so DB files are owned by user
# Source env vars if available
ssh $REMOTE_USER@$REMOTE_HOST "
cd $REMOTE_DIR
if [ -f config/sensorpajen.env ]; then
set -a
source config/sensorpajen.env
set +a
fi
.venv/bin/python3 -m sensorpajen.main > dev_backend.log 2>&1 &
echo \$!
" > .remote_pid
BG_PID=$(cat .remote_pid)
rm .remote_pid
log "Dev backend started with PID: $BG_PID"
# Wait a moment for backend to initialize DB
sleep 3
# 5. Run TUI
log "Launching TUI..."
log "${GREEN}Disconnecting will stop the dev backend and restart the service.${NC}"
ssh -t $REMOTE_USER@$REMOTE_HOST "
cd $REMOTE_DIR
source .venv/bin/activate
if [ -f config/sensorpajen.env ]; then
set -a
source config/sensorpajen.env
set +a
fi
export TUI_LOG_FILE=dev_backend.log
# Run TUI
python3 -m sensorpajen.tui.app
"
# Cleanup happens via trap

184
scripts/verify-deb.sh Executable file
View File

@@ -0,0 +1,184 @@
#!/bin/bash
# Automated verification script for Debian package
set -e
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "======================================================================"
echo " Sensorpajen Debian Package Verification"
echo "======================================================================"
echo ""
# Check dependencies
echo -n "Checking for dpkg-deb... "
if command -v dpkg-deb >/dev/null 2>&1; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${RED}MISSING${NC}"
echo "Install with: sudo apt install dpkg-dev"
exit 1
fi
echo -n "Checking for lintian... "
if command -v lintian >/dev/null 2>&1; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}MISSING${NC}"
echo "Install with: sudo apt install lintian"
echo "Continuing without lintian checks..."
SKIP_LINTIAN=1
fi
echo -n "Checking for debhelper... "
if dpkg -l debhelper >/dev/null 2>&1; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}MISSING${NC}"
echo "Install with: sudo apt install debhelper"
fi
echo ""
# Get project root
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
cd "$PROJECT_ROOT"
# Check required files exist
echo "Checking required files..."
REQUIRED_FILES=(
"debian/control"
"debian/rules"
"debian/install"
"debian/changelog"
"debian/postinst"
"debian/prerm"
"debian/postrm"
"debian/sensorpajen.service"
"src/sensorpajen/main.py"
"pyproject.toml"
)
# Optional files (debian/compat is now optional - use Build-Depends instead)
OPTIONAL_FILES=(
"debian/compat"
)
ALL_FILES_OK=1
for file in "${REQUIRED_FILES[@]}"; do
echo -n " $file... "
if [ -f "$file" ]; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${RED}MISSING${NC}"
ALL_FILES_OK=0
fi
done
# Check optional files
for file in "${OPTIONAL_FILES[@]}"; do
echo -n " $file... "
if [ -f "$file" ]; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}OPTIONAL${NC}"
fi
done
if [ $ALL_FILES_OK -eq 0 ]; then
echo -e "${RED}Some required files are missing!${NC}"
exit 1
fi
echo ""
# Extract version from pyproject.toml
VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "Package version: $VERSION"
echo ""
# Clean previous builds
echo "Cleaning previous builds..."
rm -f ../*.deb ../*.build ../*.buildinfo ../*.changes
rm -rf debian/.debhelper debian/sensorpajen debian/files
# Build the package with gzip compression (for compatibility)
echo "Building Debian package..."
echo "======================================================================"
dpkg-buildpackage -us -uc -b -Zgzip
if [ $? -ne 0 ]; then
echo -e "${RED}Build failed!${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}Build successful!${NC}"
echo ""
# Find the built package
DEB_FILE=$(ls -t ../*.deb 2>/dev/null | head -1)
if [ -z "$DEB_FILE" ]; then
echo -e "${RED}No .deb file found!${NC}"
exit 1
fi
echo "Package: $DEB_FILE"
echo ""
# Show package contents
echo "Package contents:"
echo "======================================================================"
dpkg-deb -c "$DEB_FILE" | sed -n '1,20p'
TOTAL_FILES=$(dpkg-deb -c "$DEB_FILE" | wc -l)
if [ $TOTAL_FILES -gt 20 ]; then
echo "... and $(($TOTAL_FILES - 20)) more files"
fi
echo ""
# Show package info
echo "Package information:"
echo "======================================================================"
dpkg-deb -I "$DEB_FILE"
echo ""
# Run lintian if available
if [ -z "$SKIP_LINTIAN" ]; then
echo "Running lintian checks..."
echo "======================================================================"
# Run lintian - allow warnings but fail on errors
if lintian "$DEB_FILE"; then
echo -e "${GREEN}Lintian passed!${NC}"
else
LINTIAN_EXIT=$?
echo -e "${YELLOW}Lintian found issues (exit code: $LINTIAN_EXIT)${NC}"
echo "Review the output above. Warnings are acceptable, errors should be fixed."
fi
echo ""
fi
# Final summary
echo "======================================================================"
echo -e "${GREEN}Package verification complete!${NC}"
echo "======================================================================"
echo ""
echo "Package location: $DEB_FILE"
echo ""
echo "To install on a Raspberry Pi:"
echo " scp $DEB_FILE pi@raspberrypi:~/"
echo " ssh pi@raspberrypi"
echo " sudo apt install ./$(basename $DEB_FILE)"
echo ""
echo "To test locally (not recommended, will modify /opt and /etc):"
echo " sudo apt install $DEB_FILE"
echo ""
exit 0

View File

@@ -5,6 +5,6 @@ Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
and publishes data to MQTT broker.
"""
__version__ = "2.0.0-dev"
__version__ = "3.0.0"
__author__ = "Fredrik"
__license__ = "MIT"

View File

@@ -153,8 +153,10 @@ def approve_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
print(f" Name: {name}")
print(f" Configuration will be reloaded automatically within 15 minutes")
# Mark as approved in discovery manager
# Mark as approved in discovery manager and save
print(f"\nUpdating discovery status...")
manager.approve(sensor.mac)
print(f"✅ Marked as approved in discovered_sensors.json")
except Exception as e:
print(f"\n❌ Error saving to sensors.json: {e}")
@@ -172,7 +174,7 @@ def ignore_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
manager.ignore(sensor.mac, reason if reason else None)
print(f"\n✅ Sensor ignored")
print(f"\n✅ Sensor ignored and marked in discovered_sensors.json")
if reason:
print(f" Reason: {reason}")

View File

@@ -9,12 +9,35 @@ import os
import json
import logging
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# Determine project root (3 levels up from this file: src/sensorpajen/config.py)
PROJECT_ROOT = Path(__file__).parent.parent.parent
# Determine project root and config directory
# Check if running from system installation (/opt/sensorpajen)
current_file = Path(__file__).resolve()
is_system_install = str(current_file).startswith('/opt/sensorpajen')
if is_system_install:
# System installation
PROJECT_ROOT = Path('/opt/sensorpajen')
CONFIG_DIR = Path('/etc/sensorpajen')
STATE_DIR = Path('/var/lib/sensorpajen')
else:
# Development installation
# Enforce using directory explicitly for dev-remote case
# In dev-remote, we run from ~/sensorpajen-dev context
# Check if we are in sensorpajen-dev environment
cwd = Path.cwd()
if 'sensorpajen-dev' in str(cwd) or (cwd / 'config').exists():
PROJECT_ROOT = cwd
else:
# Fallback logic
PROJECT_ROOT = Path(__file__).parent.parent.parent
CONFIG_DIR = PROJECT_ROOT / "config"
STATE_DIR = CONFIG_DIR
# MQTT Configuration from environment
MQTT_HOST = os.environ.get("MQTT_HOST")
@@ -24,17 +47,18 @@ MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen")
MQTT_TOPIC_PREFIX = os.environ.get("MQTT_TOPIC_PREFIX", "MiTemperature2")
# Validate required MQTT configuration
if not MQTT_HOST:
def validate_mqtt_config():
"""Validate that required MQTT configuration is present."""
if not MQTT_HOST:
raise RuntimeError(
"MQTT_HOST environment variable must be set. "
"Please configure config/sensorpajen.env"
)
# Sensor configuration file (relative to project root)
# Sensor configuration file
SENSOR_CONFIG_FILE = os.environ.get(
"SENSOR_CONFIG_FILE",
str(PROJECT_ROOT / "config/sensors.json")
str(CONFIG_DIR / "sensors.json")
)
# Application settings
@@ -55,7 +79,11 @@ NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
# Discovery settings
DISCOVERED_SENSORS_FILE = os.environ.get(
"DISCOVERED_SENSORS_FILE",
str(PROJECT_ROOT / "config/discovered_sensors.json")
str(STATE_DIR / "discovered_sensors.json")
)
DATABASE_FILE = os.environ.get(
"DATABASE_FILE",
str(STATE_DIR / "sensorpajen.db")
)
CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes
@@ -72,16 +100,17 @@ class SensorConfig:
"""
self.config_file = Path(config_file)
self.sensors: Dict[str, str] = {}
self.comments: Dict[str, str] = {}
self.load()
def load(self):
"""Load sensor configuration from JSON file."""
if not self.config_file.exists():
raise FileNotFoundError(
logger.warning(
f"Sensor configuration file not found: {self.config_file}\n"
f"Please copy config/sensors.json.example to config/sensors.json "
f"and configure your sensors."
f"Starting with no sensors - use discovery to add sensors"
)
return
try:
with open(self.config_file, 'r') as f:
@@ -91,9 +120,12 @@ class SensorConfig:
for sensor in data.get('sensors', []):
mac = sensor.get('mac', '').upper()
name = sensor.get('name')
comment = sensor.get('comment')
if mac and name:
self.sensors[mac] = name
if isinstance(comment, str) and comment != "":
self.comments[mac] = comment
logger.debug(f"Loaded sensor: {mac} -> {name}")
logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}")
@@ -119,13 +151,190 @@ class SensorConfig:
"""Get list of all configured MAC addresses."""
return list(self.sensors.keys())
def get_comment(self, mac: str) -> Optional[str]:
"""Get sensor comment by MAC address, if present."""
return self.comments.get(mac.upper())
def add_sensor(self, mac: str, name: str, comment: Optional[str] = None):
"""
Add or update a sensor in the configuration.
Args:
mac: MAC address
name: Sensor name
comment: Optional comment
"""
mac = mac.upper()
logger.debug(f"add_sensor called: MAC={mac}, name={name}")
self.sensors[mac] = name
if comment is not None:
# Allow explicit clearing by passing empty string
if comment == "":
self.comments.pop(mac, None)
else:
self.comments[mac] = comment
logger.debug(f"Updated in-memory dict: {mac} -> {name}")
logger.debug(f"Current sensors dict: {self.sensors}")
try:
self.save(mac, name, comment)
logger.info(f"Successfully saved sensor {mac}={name}")
except Exception as e:
# If save fails, remove from memory too
logger.error(f"Failed to save sensor {mac}: {e}")
if mac in self.sensors:
del self.sensors[mac]
raise e
def remove_sensor(self, mac: str):
"""
Remove a sensor from the configuration.
Args:
mac: MAC address
"""
mac = mac.upper()
if mac in self.sensors:
del self.sensors[mac]
self.comments.pop(mac, None)
# Load current file, remove entry, and save
try:
if self.config_file.exists():
with open(self.config_file, 'r') as f:
data = json.load(f)
sensors = data.get('sensors', [])
data['sensors'] = [s for s in sensors if s.get('mac', '').upper() != mac]
with open(self.config_file, 'w') as f:
json.dump(data, f, indent=2)
logger.info(f"Removed sensor {mac} from {self.config_file}")
except Exception as e:
logger.error(f"Error removing sensor from config: {e}")
def save(self, mac: str, name: str, comment: Optional[str] = None):
"""
Save a sensor to the configuration file.
Args:
mac: MAC address
name: Sensor name
comment: Optional comment
"""
mac = mac.upper()
logger.debug(f"save() called for MAC={mac}, name={name}")
data = {"sensors": []}
try:
if self.config_file.exists():
logger.debug(f"Reading existing config from {self.config_file}")
with open(self.config_file, 'r') as f:
data = json.load(f)
logger.debug(f"Loaded config with {len(data.get('sensors', []))} sensors")
else:
logger.debug(f"Config file does not exist: {self.config_file}")
sensors = data.get('sensors', [])
# Update existing or add new
found = False
for s in sensors:
if s.get('mac', '').upper() == mac:
logger.debug(f"Found existing sensor entry for {mac}, updating name")
s['name'] = name
if comment is not None:
if comment == "":
s.pop('comment', None)
else:
s['comment'] = comment
found = True
break
if not found:
logger.debug(f"Sensor {mac} not found in config, adding new entry")
new_sensor = {"mac": mac, "name": name}
if comment is not None and comment != "":
new_sensor["comment"] = comment
sensors.append(new_sensor)
data['sensors'] = sensors
# Ensure directory exists
self.config_file.parent.mkdir(parents=True, exist_ok=True)
logger.debug(f"Writing {len(sensors)} sensors to {self.config_file}")
with open(self.config_file, 'w') as f:
json.dump(data, f, indent=2)
logger.info(f"Saved sensor {mac} to {self.config_file}")
# Verify the write
with open(self.config_file, 'r') as f:
saved_data = json.load(f)
saved_sensors = saved_data.get('sensors', [])
logger.debug(f"Verification: File now contains {len(saved_sensors)} sensors")
for s in saved_sensors:
if s.get('mac', '').upper() == mac:
logger.debug(f"Verification: Found {mac} in file with name={s.get('name')}")
except Exception as e:
logger.error(f"Error saving sensor config: {e}", exc_info=True)
raise e
def save_env_var(key: str, value: str):
"""
Update a value in the sensorpajen.env file.
Args:
key: Environment variable name
value: New value
"""
env_file = CONFIG_DIR / "sensorpajen.env"
if not env_file.exists():
raise FileNotFoundError(f"Env file not found: {env_file}")
try:
lines = []
with open(env_file, 'r') as f:
lines = f.readlines()
new_lines = []
found = False
for line in lines:
if line.strip().startswith(f"{key}="):
new_lines.append(f"{key}={value}\n")
found = True
else:
new_lines.append(line)
if not found:
# Add to end if not found
if new_lines and not new_lines[-1].endswith('\n'):
new_lines.append('\n')
new_lines.append(f"{key}={value}\n")
with open(env_file, 'w') as f:
f.writelines(new_lines)
logger.info(f"Updated {key} in {env_file}")
except Exception as e:
logger.error(f"Error updating env file: {e}")
raise e
def validate_config():
"""
Validate configuration and log settings.
Should be called at application startup.
"""
validate_mqtt_config()
install_type = "System" if Path('/opt/sensorpajen').exists() else "Development"
logger.info("=== Sensorpajen Configuration ===")
logger.info(f"Installation Type: {install_type}")
logger.info(f"Project Root: {PROJECT_ROOT}")
logger.info(f"Config Directory: {CONFIG_DIR}")
logger.info(f"State Directory: {STATE_DIR}")
logger.info(f"MQTT Host: {MQTT_HOST}:{MQTT_PORT}")
logger.info(f"MQTT User: {MQTT_USER}")
logger.info(f"MQTT Client ID: {MQTT_CLIENT_ID}")

114
src/sensorpajen/db.py Normal file
View File

@@ -0,0 +1,114 @@
import sqlite3
import logging
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
class DatabaseManager:
"""Manages SQLite database for discovered sensors."""
def __init__(self, db_path: str):
self.db_path = Path(db_path)
def _get_connection(self):
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
def initialize(self):
"""Initialize the database schema."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS discovered_sensors (
mac TEXT PRIMARY KEY,
name TEXT,
rssi INTEGER,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
count INTEGER DEFAULT 0,
last_temp REAL,
last_humidity REAL,
last_battery_percent INTEGER,
last_battery_voltage INTEGER,
status TEXT DEFAULT 'pending',
reviewed BOOLEAN DEFAULT 0,
ignored_at TIMESTAMP,
ignore_reason TEXT
)
""")
logger.info(f"Database initialized at {self.db_path}")
def add_or_update_sensor(self, mac: str, name: str, rssi: int,
temp: float, humidity: float,
battery_percent: int, battery_voltage: int):
"""Add a new sensor or update an existing one."""
now = datetime.now().isoformat()
mac = mac.upper()
with self._get_connection() as conn:
# Check if exists
cursor = conn.execute("SELECT count, first_seen FROM discovered_sensors WHERE mac = ?", (mac,))
row = cursor.fetchone()
if row:
count = row['count'] + 1
conn.execute("""
UPDATE discovered_sensors SET
name = ?,
rssi = ?,
last_seen = ?,
count = ?,
last_temp = ?,
last_humidity = ?,
last_battery_percent = ?,
last_battery_voltage = ?
WHERE mac = ?
""", (name, rssi, now, count, temp, humidity, battery_percent, battery_voltage, mac))
else:
conn.execute("""
INSERT INTO discovered_sensors (
mac, name, rssi, first_seen, last_seen, count,
last_temp, last_humidity, last_battery_percent, last_battery_voltage
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (mac, name, rssi, now, now, 1, temp, humidity, battery_percent, battery_voltage))
def get_sensor(self, mac: str) -> Optional[Dict[str, Any]]:
"""Get a single sensor by MAC."""
with self._get_connection() as conn:
cursor = conn.execute("SELECT * FROM discovered_sensors WHERE mac = ?", (mac.upper(),))
row = cursor.fetchone()
return dict(row) if row else None
def get_sensors(self, status: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get all sensors, optionally filtered by status."""
query = "SELECT * FROM discovered_sensors"
params = []
if status:
query += " WHERE status = ?"
params.append(status)
with self._get_connection() as conn:
cursor = conn.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
def update_status(self, mac: str, status: str, reason: Optional[str] = None):
"""Update the status of a sensor."""
now = datetime.now().isoformat() if status == 'ignored' else None
with self._get_connection() as conn:
conn.execute("""
UPDATE discovered_sensors SET
status = ?,
ignored_at = ?,
ignore_reason = ?
WHERE mac = ?
""", (status, now, reason, mac.upper()))
def mark_reviewed(self, mac: str):
"""Mark a sensor as reviewed."""
with self._get_connection() as conn:
conn.execute("UPDATE discovered_sensors SET reviewed = 1 WHERE mac = ?", (mac.upper(),))

View File

@@ -4,15 +4,14 @@ Discovery manager for tracking and managing discovered sensors.
Maintains a database of discovered sensors with their metadata and status.
"""
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
from dataclasses import dataclass
from . import config
from .db import DatabaseManager
logger = logging.getLogger(__name__)
@@ -30,61 +29,45 @@ class DiscoveredSensor:
reviewed: bool = False # Has been shown in approval CLI
ignored_at: Optional[str] = None
ignore_reason: Optional[str] = None
count: int = 0
class DiscoveryManager:
"""Manages discovered sensors and their approval status."""
"""Manages discovered sensors and their approval status using SQLite."""
def __init__(self, discovery_file: str = config.DISCOVERED_SENSORS_FILE):
def __init__(self, db_path: str = config.DATABASE_FILE, sensor_config: Optional[config.SensorConfig] = None):
"""
Initialize discovery manager.
Args:
discovery_file: Path to discovered sensors JSON file
db_path: Path to SQLite database file
sensor_config: Optional reference to SensorConfig to filter pending list
"""
self.discovery_file = Path(discovery_file)
self.sensors: Dict[str, DiscoveredSensor] = {}
self.load()
self.db = DatabaseManager(db_path)
self.db.initialize()
self.sensor_config = sensor_config
def load(self):
"""Load discovered sensors from JSON file."""
if not self.discovery_file.exists():
logger.info(f"Creating new discovered sensors file: {self.discovery_file}")
self.discovery_file.parent.mkdir(parents=True, exist_ok=True)
self.save()
return
try:
with open(self.discovery_file, 'r') as f:
data = json.load(f)
for sensor_data in data:
sensor = DiscoveredSensor(**sensor_data)
self.sensors[sensor.mac.upper()] = sensor
logger.info(f"Loaded {len(self.sensors)} discovered sensors")
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in {self.discovery_file}: {e}")
except Exception as e:
logger.error(f"Error loading discovered sensors: {e}")
def save(self):
"""Save discovered sensors to JSON file."""
try:
# Ensure directory exists
self.discovery_file.parent.mkdir(parents=True, exist_ok=True)
# Convert sensors to list of dicts
data = [asdict(sensor) for sensor in self.sensors.values()]
with open(self.discovery_file, 'w') as f:
json.dump(data, f, indent=2)
logger.debug(f"Saved {len(self.sensors)} discovered sensors")
except Exception as e:
logger.error(f"Error saving discovered sensors: {e}")
def _row_to_sensor(self, row: Dict) -> DiscoveredSensor:
"""Convert database row to DiscoveredSensor object."""
return DiscoveredSensor(
mac=row['mac'],
name=row['name'],
rssi=row['rssi'],
first_seen=row['first_seen'],
last_seen=row['last_seen'],
sample_reading={
"temperature": row['last_temp'],
"humidity": row['last_humidity'],
"battery_percent": row['last_battery_percent'],
"battery_voltage": row['last_battery_voltage']
},
status=row['status'],
reviewed=bool(row['reviewed']),
ignored_at=row['ignored_at'],
ignore_reason=row['ignore_reason'],
count=row['count']
)
def add_or_update(self, mac: str, name: str, rssi: int,
temperature: float, humidity: float,
@@ -92,137 +75,92 @@ class DiscoveryManager:
"""
Add or update a discovered sensor.
Args:
mac: MAC address
name: Advertised device name
rssi: Signal strength
temperature: Temperature reading
humidity: Humidity reading
battery_percent: Battery percentage
battery_voltage: Battery voltage in mV
Returns:
True if this is a newly discovered sensor, False if updated existing
"""
mac = mac.upper()
now = datetime.now().isoformat()
existing = self.db.get_sensor(mac)
sample_reading = {
"temperature": temperature,
"humidity": humidity,
"battery_percent": battery_percent,
"battery_voltage": battery_voltage
}
if mac in self.sensors:
# Update existing sensor
sensor = self.sensors[mac]
sensor.last_seen = now
sensor.rssi = rssi
sensor.sample_reading = sample_reading
self.save()
return False
else:
# New sensor discovered
sensor = DiscoveredSensor(
self.db.add_or_update_sensor(
mac=mac,
name=name,
rssi=rssi,
first_seen=now,
last_seen=now,
sample_reading=sample_reading,
status="pending"
temp=temperature,
humidity=humidity,
battery_percent=battery_percent,
battery_voltage=battery_voltage
)
self.sensors[mac] = sensor
self.save()
if not existing:
logger.info(f"New sensor discovered: {mac} ({name})")
# Send notification for new sensors ONLY if they are not already configured
is_configured = False
if self.sensor_config:
is_configured = mac in self.sensor_config.get_all_macs()
if not is_configured:
sensor = self._row_to_sensor(self.db.get_sensor(mac))
self.send_ntfy_notification(sensor)
else:
logger.debug(f"Sensor {mac} is configured, skipping new sensor notification")
return True
return False
def is_known(self, mac: str) -> bool:
"""
Check if a sensor has been discovered before.
Args:
mac: MAC address
Returns:
True if sensor is in discovered list
"""
return mac.upper() in self.sensors
"""Check if a sensor has been discovered before."""
return self.db.get_sensor(mac) is not None
def get_status(self, mac: str) -> Optional[str]:
"""
Get status of a discovered sensor.
Args:
mac: MAC address
Returns:
Status string or None if not found
"""
sensor = self.sensors.get(mac.upper())
return sensor.status if sensor else None
"""Get status of a discovered sensor."""
sensor = self.db.get_sensor(mac)
return sensor['status'] if sensor else None
def approve(self, mac: str):
"""
Mark a sensor as approved.
Args:
mac: MAC address
"""
mac = mac.upper()
if mac in self.sensors:
self.sensors[mac].status = "approved"
self.save()
"""Mark a sensor as approved."""
self.db.update_status(mac, "approved")
logger.info(f"Sensor approved: {mac}")
def ignore(self, mac: str, reason: Optional[str] = None):
"""
Mark a sensor as ignored.
Args:
mac: MAC address
reason: Optional reason for ignoring
"""
mac = mac.upper()
if mac in self.sensors:
self.sensors[mac].status = "ignored"
self.sensors[mac].ignored_at = datetime.now().isoformat()
self.sensors[mac].ignore_reason = reason
self.save()
"""Mark a sensor as ignored."""
self.db.update_status(mac, "ignored", reason)
logger.info(f"Sensor ignored: {mac}")
def unignore(self, mac: str):
"""Mark an ignored sensor as pending again."""
self.db.update_status(mac, "pending")
logger.info(f"Sensor unignored: {mac}")
def get_pending(self) -> List[DiscoveredSensor]:
"""Get list of sensors with status 'pending'."""
return [s for s in self.sensors.values() if s.status == "pending"]
rows = self.db.get_sensors(status="pending")
sensors = [self._row_to_sensor(r) for r in rows]
if self.sensor_config:
# Filter out sensors that are already configured
# (Use MAC from DB row vs keys in sensor_config)
configured_macs = self.sensor_config.get_all_macs()
return [s for s in sensors if s.mac not in configured_macs]
return sensors
def get_new_pending(self) -> List[DiscoveredSensor]:
"""Get list of pending sensors that haven't been reviewed yet."""
return [s for s in self.sensors.values() if s.status == "pending" and not s.reviewed]
rows = self.db.get_sensors(status="pending")
return [self._row_to_sensor(r) for r in rows if not r.reviewed]
def get_ignored(self) -> List[DiscoveredSensor]:
"""Get list of sensors with status 'ignored'."""
return [s for s in self.sensors.values() if s.status == "ignored"]
rows = self.db.get_sensors(status="ignored")
return [self._row_to_sensor(r) for r in rows]
def mark_reviewed(self, mac: str):
"""
Mark a sensor as reviewed (shown in approval CLI).
Args:
mac: MAC address
"""
mac = mac.upper()
if mac in self.sensors:
self.sensors[mac].reviewed = True
self.save()
"""Mark a sensor as reviewed."""
self.db.mark_reviewed(mac)
def send_ntfy_notification(self, sensor: DiscoveredSensor):
"""
Send ntfy notification for a newly discovered sensor.
Args:
sensor: Discovered sensor to notify about
"""
"""Send ntfy notification for a newly discovered sensor."""
if not config.NTFY_ENABLED:
logger.debug("ntfy notifications disabled")
return
@@ -240,7 +178,7 @@ class DiscoveryManager:
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C\n"
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%\n"
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%\n\n"
f"Run 'sensorpajen approve-sensors' to approve or ignore."
f"Run 'sensorpajen-tui' to approve or ignore."
)
url = f"{config.NTFY_URL}/{config.NTFY_TOPIC}"

View File

@@ -125,13 +125,13 @@ class Sensorpajen:
self.sensor_config = config.SensorConfig()
if len(self.sensor_config.sensors) == 0:
self.logger.error("No sensors configured!")
self.logger.error("Please configure sensors in config/sensors.json")
sys.exit(1)
self.logger.warning("No sensors configured")
self.logger.warning("Starting in discovery-only mode")
self.logger.warning("Use 'sensorpajen-tui' to add sensors")
# Initialize discovery manager
self.logger.info("Initializing discovery manager...")
self.discovery_manager = DiscoveryManager()
self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config)
# Initialize MQTT publisher
self.logger.info("Initializing MQTT publisher...")

View File

@@ -0,0 +1,62 @@
import json
import logging
from pathlib import Path
from . import config
from .db import DatabaseManager
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def migrate():
json_file = Path(config.DISCOVERED_SENSORS_FILE)
db_file = Path(config.DATABASE_FILE)
if not json_file.exists():
logger.info(f"No JSON discovery file found at {json_file}. Nothing to migrate.")
return
logger.info(f"Migrating data from {json_file} to {db_file}")
db = DatabaseManager(str(db_file))
db.initialize()
try:
with open(json_file, 'r') as f:
sensors = json.load(f)
for s in sensors:
mac = s.get('mac')
name = s.get('name', 'Unknown')
rssi = s.get('rssi', 0)
reading = s.get('sample_reading', {})
db.add_or_update_sensor(
mac=mac,
name=name,
rssi=rssi,
temp=reading.get('temperature', 0),
humidity=reading.get('humidity', 0),
battery_percent=reading.get('battery_percent', 0),
battery_voltage=reading.get('battery_voltage', 0)
)
# Update status and metadata
status = s.get('status', 'pending')
reason = s.get('ignore_reason')
db.update_status(mac, status, reason)
if s.get('reviewed'):
db.mark_reviewed(mac)
logger.info(f"Successfully migrated {len(sensors)} sensors.")
# Rename old file to avoid re-migration
backup_file = json_file.with_suffix('.json.bak')
json_file.rename(backup_file)
logger.info(f"Original file backed up to {backup_file}")
except Exception as e:
logger.error(f"Migration failed: {e}")
if __name__ == "__main__":
migrate()

View File

@@ -17,6 +17,7 @@ class MQTTPublisher:
def __init__(self):
"""Initialize MQTT publisher with configuration."""
config.validate_mqtt_config()
self.client: Optional[mqtt.Client] = None
self.connected = False
self._setup_client()

View File

@@ -196,6 +196,19 @@ class SensorReader:
# Create measurement for known sensor
sensor_name = self.sensor_config.get_name(mac_with_colons)
# --- PHASE 2: Update live data in DB for TUI ---
self.discovery_manager.add_or_update(
mac_with_colons,
sensor_name,
rssi,
temperature,
humidity,
battery_percent,
battery_voltage
)
# -----------------------------------------------
measurement = Measurement(
temperature=temperature,
humidity=humidity,
@@ -254,9 +267,8 @@ class SensorReader:
)
if is_new:
logger.info(f"New sensor discovered: {mac} ({device_name})")
sensor = self.discovery_manager.sensors[mac]
self.discovery_manager.send_ntfy_notification(sensor)
# Notification is handled by DiscoveryManager
pass
def _parse_atc_data(self, data_str: str) -> Optional[tuple]:
"""

700
src/sensorpajen/tui/app.py Normal file
View File

@@ -0,0 +1,700 @@
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable, Static, Button
from textual.containers import Container, Horizontal
from textual import on
import asyncio
import logging
import os
from pathlib import Path
from typing import Callable, Optional
from ..discovery_manager import DiscoveryManager
from ..config import SensorConfig, save_env_var
from .modals import InputModal, ConfirmModal, DetailsModal, EditSensorModal
def _format_metadata_comment(mac: str, name: str, last_seen: str, sample_reading: dict) -> str:
return (
f"MAC: {mac}, "
f"Name: {name}, "
f"Last seen: {last_seen}, "
f"Temp: {sample_reading.get('temperature', 'N/A')}°C, "
f"Humidity: {sample_reading.get('humidity', 'N/A')}%, "
f"Battery: {sample_reading.get('battery_percent', 'N/A')}%"
)
def _setup_tui_file_logging() -> logging.Logger:
"""Log to a file (not stdout/stderr) to avoid breaking Textual fullscreen UI."""
logger = logging.getLogger("sensorpajen.tui")
if logger.handlers:
return logger
log_file = Path(os.environ.get("TUI_LOG_FILE", "dev_backend.log"))
if not log_file.is_absolute():
log_file = Path.cwd() / log_file
try:
handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
logger.propagate = False
logger.info("TUI logging enabled -> %s", log_file)
except Exception as exc:
# If we can't write logs, keep UI working.
try:
logger.setLevel(logging.CRITICAL)
logger.propagate = False
except Exception:
pass
return logger
tui_logger = _setup_tui_file_logging()
class SensorpajenApp(App):
"""A Textual app to manage Bluetooth sensors."""
CSS = """
Screen {
background: $surface;
}
DataTable {
height: 1fr;
margin: 1;
}
#modal-container {
width: 50;
height: auto;
background: $panel;
border: thick $primary;
padding: 1;
align: center middle;
}
#modal-buttons {
margin-top: 1;
height: auto;
align: center middle;
}
#modal-buttons Button {
margin: 0 1;
}
/* Dashboard specific CSS */
.dashboard-row {
height: auto;
margin: 1;
}
.dash-stat {
width: 1fr;
height: auto;
border: solid $accent;
padding: 1;
margin: 1;
text-align: center;
}
.dashboard-controls {
height: auto;
align: center middle;
margin-top: 2;
}
.dashboard-controls Button {
margin: 0 2;
}
"""
# Use priority bindings so keys still reach the App even when a DataTable has focus.
BINDINGS = [
Binding("q", "quit", "Quit", priority=True),
Binding("d", "toggle_dark", "Toggle dark mode", priority=True),
Binding("r", "refresh", "Refresh data", priority=True),
Binding("a", "approve", "Approve", priority=True),
Binding("i", "ignore", "Ignore", priority=True),
Binding("e", "edit", "Edit", priority=True),
Binding("v", "view_details", "Details", priority=True),
Binding("u", "unignore", "Unignore", priority=True),
Binding("delete", "remove", "Remove", priority=True),
]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.sensor_config = SensorConfig()
# Pass sensor_config to discovery manager for filtering
self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config)
try:
tui_logger.info(
"TUI init: sensors_file=%s, configured=%d",
getattr(self.sensor_config, "config_file", None),
len(getattr(self.sensor_config, "sensors", {})),
)
except Exception:
pass
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
with TabbedContent(initial="discovery"):
with TabPane("Discovery", id="discovery"):
yield DataTable(id="discovery-table", cursor_type="row")
with TabPane("Configured", id="configured"):
yield DataTable(id="configured-table", cursor_type="row")
with TabPane("Ignored", id="ignored"):
yield DataTable(id="ignored-table", cursor_type="row")
with TabPane("Settings", id="settings"):
yield DataTable(id="settings-table", cursor_type="row")
with TabPane("Dashboard", id="dashboard"):
with Horizontal(classes="dashboard-row"):
yield Static("CPU Temp: ...", id="dash-cpu-temp", classes="dash-stat")
yield Static("Load Avg: ...", id="dash-load", classes="dash-stat")
with Horizontal(classes="dashboard-row"):
yield Static("Memory: ...", id="dash-memory", classes="dash-stat")
yield Static("Disk: ...", id="dash-disk", classes="dash-stat")
with Horizontal(classes="dashboard-controls"):
yield Button("Restart Service", id="btn-restart-service", variant="warning")
yield Button("Stop Service", id="btn-stop-service", variant="error")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "btn-restart-service":
self.action_restart_service()
elif event.button.id == "btn-stop-service":
self.action_stop_service()
def action_restart_service(self) -> None:
"""Restart the system service."""
import subprocess
try:
subprocess.run(["sudo", "systemctl", "restart", "sensorpajen"], check=False)
self.notify("Service restart triggered", severity="information")
except Exception as e:
self.notify(f"Failed to restart: {e}", severity="error")
def action_stop_service(self) -> None:
"""Stop the system service."""
import subprocess
try:
subprocess.run(["sudo", "systemctl", "stop", "sensorpajen"], check=False)
self.notify("Service stop triggered", severity="warning")
except Exception as e:
self.notify(f"Failed to stop: {e}", severity="error")
def on_mount(self) -> None:
"""Handle app mount event."""
self.refresh_data()
def _open_input_modal(
self,
title: str,
*,
initial_value: str = "",
placeholder: str = "",
on_result: Callable[[Optional[str]], None],
) -> None:
"""Open an input modal and handle the result via callback.
This avoids awaiting modal results inside action handlers, which can
freeze / deadlock depending on Textual version and context.
"""
modal = InputModal(title, placeholder=placeholder, initial_value=initial_value)
self.push_screen(modal, on_result)
def _open_confirm_modal(
self,
title: str,
message: str,
*,
on_result: Callable[[bool], None],
confirm_label: str = "Yes",
cancel_label: str = "No",
) -> None:
modal = ConfirmModal(
title,
message,
confirm_label=confirm_label,
cancel_label=cancel_label,
)
self.push_screen(modal, on_result)
def _open_details_modal(self, title: str, details_text: str) -> None:
modal = DetailsModal(title, details_text)
# No callback needed
self.push_screen(modal)
async def _save_sensor(self, mac: str, name: str, comment: Optional[str] = None) -> None:
await asyncio.to_thread(self.sensor_config.add_sensor, mac, name, comment)
async def _remove_sensor(self, mac: str) -> None:
await asyncio.to_thread(self.sensor_config.remove_sensor, mac)
def action_refresh(self) -> None:
"""Refresh all tables."""
self.refresh_data()
async def action_approve(self) -> None:
"""Approve the selected discovered sensor."""
if self.query_one(TabbedContent).active != "discovery":
return
table = self.query_one("#discovery-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
mac = row[0]
default_name = row[1]
# Get a richer sensor object for metadata (best-effort)
sensor_obj = None
try:
for s in self.discovery_manager.get_pending():
if getattr(s, "mac", "").upper() == str(mac).upper():
sensor_obj = s
break
except Exception:
sensor_obj = None
default_comment = _format_metadata_comment(
str(mac),
getattr(sensor_obj, "name", default_name),
getattr(sensor_obj, "last_seen", "N/A"),
getattr(sensor_obj, "sample_reading", {}) or {},
)
def _on_result(result: object) -> None:
if result is None:
return
try:
name, comment = result # type: ignore[misc]
except Exception:
self.notify("Invalid approve result", severity="error")
return
name_stripped = str(name).strip()
if not name_stripped:
self.notify("Sensor name cannot be empty", severity="error")
return
# Match legacy behavior: empty comment falls back to default metadata.
comment_stripped = str(comment).strip()
comment_to_use = comment_stripped if comment_stripped else default_comment
async def _do() -> None:
try:
await self._save_sensor(str(mac), name_stripped, comment_to_use)
self.discovery_manager.approve(str(mac))
self.notify(f"Approved {mac} as {name_stripped}")
self.refresh_data()
except Exception as e:
self.notify(f"Error approving sensor: {e}", severity="error")
asyncio.create_task(_do())
self.push_screen(
EditSensorModal(title="Approve sensor", name=str(default_name), comment=default_comment),
_on_result,
)
async def action_ignore(self) -> None:
"""Ignore the selected discovered sensor."""
if self.query_one(TabbedContent).active != "discovery":
return
table = self.query_one("#discovery-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
mac = row[0]
def _on_reason(reason: Optional[str]) -> None:
# Allow empty string but not None (Cancel)
if reason is None:
return
try:
self.discovery_manager.ignore(mac, reason if reason else None)
self.notify(f"Ignored {mac}")
self.refresh_data()
except Exception as e:
self.notify(f"Error ignoring sensor: {e}", severity="error")
self._open_input_modal("Enter ignore reason (optional)", on_result=_on_reason)
async def action_edit(self) -> None:
"""Edit the selected item (sensor or setting)."""
active_tab = self.query_one(TabbedContent).active
if active_tab == "configured":
table = self.query_one("#configured-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
mac = str(row[0]).upper() # Ensure MAC is uppercase
current_name = str(row[1])
current_comment = self.sensor_config.get_comment(mac) or ""
try:
tui_logger.info(
"Edit configured: mac=%s current_name=%r file=%s",
mac,
current_name,
getattr(self.sensor_config, "config_file", None),
)
except Exception:
pass
current_name_stripped = current_name.strip()
current_comment_stripped = current_comment.strip()
def _on_result(result: object) -> None:
if result is None:
try:
tui_logger.info("Edit cancelled: mac=%s", mac)
except Exception:
pass
return
try:
new_name, new_comment = result # type: ignore[misc]
except Exception:
self.notify("Invalid edit result", severity="error")
return
new_name = str(new_name).strip()
new_comment = str(new_comment).strip()
if not new_name:
self.notify("Sensor name cannot be empty", severity="error")
return
name_changed = new_name != current_name_stripped
comment_changed = new_comment != current_comment_stripped
if not name_changed and not comment_changed:
try:
tui_logger.info("Edit no-op: mac=%s name/comment unchanged", mac)
except Exception:
pass
return
# Only touch comment if user changed it; empty string means clear.
comment_to_pass: Optional[str]
if comment_changed:
comment_to_pass = new_comment
else:
comment_to_pass = None
async def _do() -> None:
try:
await self._save_sensor(mac, new_name, comment_to_pass)
stored_name = self.sensor_config.sensors.get(mac)
try:
tui_logger.info(
"Edit result: mac=%s new_name=%r stored_name=%r new_comment=%r",
mac,
new_name,
stored_name,
comment_to_pass,
)
except Exception:
pass
self.notify(f"Updated {mac}")
self.refresh_data()
except Exception as e:
try:
tui_logger.exception("Error updating sensor: mac=%s", mac)
except Exception:
pass
self.notify(f"Error updating sensor: {e}", severity="error")
asyncio.create_task(_do())
self.push_screen(
EditSensorModal(name=current_name, comment=current_comment),
_on_result,
)
elif active_tab == "settings":
table = self.query_one("#settings-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
key = row[0]
current_value = row[1]
def _on_value(new_value: Optional[str]) -> None:
if new_value is None:
return
try:
save_env_var(key, new_value)
# Update current runtime env for display (won't take effect in backend until restart)
import os
os.environ[key] = new_value
self.notify(
f"Updated {key}. Restart service for changes to take effect!",
severity="warning",
)
self.refresh_data()
except Exception as e:
self.notify(f"Error saving setting: {e}", severity="error")
self._open_input_modal(f"Edit {key}", initial_value=str(current_value), on_result=_on_value)
def action_view_details(self) -> None:
"""View details for the selected sensor (shows long comment in popup)."""
active_tab = self.query_one(TabbedContent).active
table_id = None
if active_tab == "configured":
table_id = "#configured-table"
elif active_tab == "discovery":
table_id = "#discovery-table"
elif active_tab == "ignored":
table_id = "#ignored-table"
else:
return
table = self.query_one(table_id, DataTable)
if table.cursor_row is None:
self.notify("Select a sensor first", severity="warning")
return
row = table.get_row_at(table.cursor_row)
mac = str(row[0]).upper()
name = str(row[1]) if len(row) > 1 else ""
comment = None
if active_tab == "configured":
comment = self.sensor_config.get_comment(mac)
details_lines = [
f"MAC: {mac}",
f"Name: {name}",
]
if comment:
details_lines.extend(["", "Comment:", comment])
else:
details_lines.extend(["", "Comment:", "(none)"])
self._open_details_modal("Sensor details", "\n".join(details_lines))
def action_remove(self) -> None:
"""Remove the selected configured sensor."""
if self.query_one(TabbedContent).active != "configured":
return
table = self.query_one("#configured-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
mac = row[0]
def _on_confirm(confirmed: bool) -> None:
if not confirmed:
return
async def _do() -> None:
try:
await self._remove_sensor(str(mac))
# Also need to reset its status in DiscoveryManager to make it show up in Discovery again
self.discovery_manager.unignore(str(mac)) # unignore sets status to 'pending'
self.notify(f"Removed {mac}")
self.refresh_data()
except Exception as e:
self.notify(f"Error removing sensor: {e}", severity="error")
asyncio.create_task(_do())
self._open_confirm_modal(
"Remove sensor",
f"Remove {mac} from configured sensors?",
confirm_label="Remove",
cancel_label="Cancel",
on_result=_on_confirm,
)
def action_unignore(self) -> None:
"""Unignore the selected sensor."""
if self.query_one(TabbedContent).active != "ignored":
return
table = self.query_one("#ignored-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
mac = row[0]
self.discovery_manager.unignore(mac)
self.notify(f"Unignored {mac}")
self.refresh_data()
def refresh_data(self) -> None:
"""Load data from managers and update tables."""
try:
self._update_discovery_table()
self._update_configured_table()
self._update_ignored_table()
self._update_settings_table()
self._update_dashboard()
except Exception as e:
self.notify(f"Error refreshing data: {e}", severity="error")
import traceback
traceback.print_exc()
def _update_discovery_table(self) -> None:
table = self.query_one("#discovery-table", DataTable)
table.clear(columns=True)
table.add_columns("MAC", "Name", "RSSI", "Last Seen", "Count", "Temp", "Humidity")
sensors = self.discovery_manager.get_pending()
for s in sensors:
table.add_row(
s.mac,
s.name,
str(s.rssi),
s.last_seen.split("T")[1].split(".")[0], # Just time
str(s.count),
f"{s.sample_reading.get('temperature', 0):.1f}°C",
f"{s.sample_reading.get('humidity', 0)}%"
)
def _update_configured_table(self) -> None:
table = self.query_one("#configured-table", DataTable)
table.clear(columns=True)
table.add_columns("MAC", "Name", "Temp", "Humidity", "Battery", "RSSI", "Last Seen")
for mac, name in self.sensor_config.sensors.items():
sensor_data = self.discovery_manager.db.get_sensor(mac)
temp = "N/A"
humidity = "N/A"
battery = "N/A"
rssi = "N/A"
last_seen = "N/A"
if sensor_data:
# sensor_data is a Row/dict
if sensor_data['last_temp'] is not None:
temp = f"{sensor_data['last_temp']:.1f}°C"
if sensor_data['last_humidity'] is not None:
humidity = f"{sensor_data['last_humidity']}%"
if sensor_data['last_battery_percent'] is not None:
battery = f"{sensor_data['last_battery_percent']}%"
if sensor_data['rssi'] is not None:
rssi = str(sensor_data['rssi'])
if sensor_data['last_seen']:
try:
# Extract time only: 2025-12-27T14:30:00 -> 14:30:00
last_seen = sensor_data['last_seen'].split("T")[1].split(".")[0]
except IndexError:
last_seen = sensor_data['last_seen']
table.add_row(mac, name, temp, humidity, battery, rssi, last_seen)
def _update_ignored_table(self) -> None:
table = self.query_one("#ignored-table", DataTable)
table.clear(columns=True)
table.add_columns("MAC", "Name", "Ignored At", "Reason")
sensors = self.discovery_manager.get_ignored()
for s in sensors:
table.add_row(
s.mac,
s.name,
s.ignored_at.split("T")[0] if s.ignored_at else "N/A",
s.ignore_reason or ""
)
def _update_settings_table(self) -> None:
import os
table = self.query_one("#settings-table", DataTable)
table.clear(columns=True)
table.add_columns("Key", "Value")
# Relevant keys to show
relevant_prefixes = ["MQTT_", "WATCHDOG_", "ENABLE_BATTERY", "LOG_LEVEL", "NTFY_", "SKIP_IDENTICAL"]
for key, value in sorted(os.environ.items()):
if any(key.startswith(p) for p in relevant_prefixes):
table.add_row(key, value)
def _update_dashboard(self) -> None:
"""Update dashboard statistics."""
try:
# CPU Temp
cpu_temp = "N/A"
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
temp_raw = int(f.read().strip())
cpu_temp = f"{temp_raw / 1000.0:.1f}°C"
except:
pass
self.query_one("#dash-cpu-temp", Static).update(f"CPU Temp\n{cpu_temp}")
# Load Avg
import os
load_avg = os.getloadavg()
self.query_one("#dash-load", Static).update(f"Load Avg\n{load_avg[0]:.2f}, {load_avg[1]:.2f}, {load_avg[2]:.2f}")
# Memory
mem_used_pct = "N/A"
try:
mem_info = {}
with open("/proc/meminfo", "r") as f:
for line in f:
parts = line.split(":")
if len(parts) == 2:
val = int(parts[1].strip().split()[0])
mem_info[parts[0]] = val
if "MemTotal" in mem_info and "MemAvailable" in mem_info:
total = mem_info["MemTotal"]
avail = mem_info["MemAvailable"]
used = total - avail
mem_used_pct = f"{(used / total) * 100:.1f}%"
except:
pass
self.query_one("#dash-memory", Static).update(f"Memory Used\n{mem_used_pct}")
# Disk
import shutil
disk_used_pct = "N/A"
try:
total, used, free = shutil.disk_usage("/")
disk_used_pct = f"{(used / total) * 100:.1f}%"
except:
pass
self.query_one("#dash-disk", Static).update(f"Disk Usage\n{disk_used_pct}")
except Exception as e:
# Don't crash TUI on stat failure
pass
def main():
app = SensorpajenApp()
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,142 @@
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.binding import Binding
from textual.widgets import Input, Label, Button
from textual.containers import Vertical, Horizontal, VerticalScroll
from textual.widgets import Static
class InputModal(ModalScreen[str]):
"""A modal screen for text input."""
def __init__(self, title: str, placeholder: str = "", initial_value: str = ""):
super().__init__()
self.title_text = title
self.placeholder = placeholder
self.initial_value = initial_value
def compose(self) -> ComposeResult:
with Vertical(id="modal-container"):
yield Label(self.title_text)
yield Input(placeholder=self.placeholder, value=self.initial_value, id="modal-input")
with Horizontal(id="modal-buttons"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", variant="error", id="cancel-btn")
def on_mount(self) -> None:
self.query_one(Input).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self.dismiss(self.query_one(Input).value)
else:
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value)
class ConfirmModal(ModalScreen[bool]):
"""A modal screen for confirming an action."""
def __init__(
self,
title: str,
message: str,
*,
confirm_label: str = "Yes",
cancel_label: str = "No",
):
super().__init__()
self.title_text = title
self.message_text = message
self.confirm_label = confirm_label
self.cancel_label = cancel_label
def compose(self) -> ComposeResult:
with Vertical(id="modal-container"):
yield Label(self.title_text)
yield Label(self.message_text)
with Horizontal(id="modal-buttons"):
yield Button(self.confirm_label, variant="warning", id="confirm-btn")
yield Button(self.cancel_label, variant="primary", id="cancel-btn")
def on_mount(self) -> None:
self.query_one("#cancel-btn", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm-btn":
self.dismiss(True)
else:
self.dismiss(False)
class DetailsModal(ModalScreen[None]):
"""A modal screen for showing potentially long details text."""
BINDINGS = [
Binding("escape", "close", show=False),
]
def __init__(self, title: str, details_text: str):
super().__init__()
self.title_text = title
self.details_text = details_text
def compose(self) -> ComposeResult:
with Vertical(id="modal-container"):
yield Label(self.title_text)
with VerticalScroll():
yield Static(self.details_text)
with Horizontal(id="modal-buttons"):
yield Button("Close", variant="primary", id="close-btn")
def action_close(self) -> None:
self.dismiss(None)
def on_mount(self) -> None:
self.query_one("#close-btn", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(None)
class EditSensorModal(ModalScreen):
"""A modal screen for editing a sensor's name and comment."""
def __init__(self, *, title: str = "Edit sensor", name: str, comment: str):
super().__init__()
self.title_text = title
self.initial_name = name
self.initial_comment = comment
def compose(self) -> ComposeResult:
with Vertical(id="modal-container"):
yield Label(self.title_text)
yield Label("Name")
yield Input(value=self.initial_name, id="sensor-name-input")
yield Label("Comment")
yield Input(value=self.initial_comment, placeholder="Optional comment", id="sensor-comment-input")
with Horizontal(id="modal-buttons"):
yield Button("OK", variant="primary", id="ok-btn")
yield Button("Cancel", variant="error", id="cancel-btn")
def on_mount(self) -> None:
self.query_one("#sensor-name-input", Input).focus()
def _dismiss_with_values(self) -> None:
name = self.query_one("#sensor-name-input", Input).value
comment = self.query_one("#sensor-comment-input", Input).value
self.dismiss((name, comment))
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self._dismiss_with_values()
else:
self.dismiss(None)
def on_input_submitted(self, event: Input.Submitted) -> None:
# Enter on name moves to comment; Enter on comment submits.
if event.input.id == "sensor-name-input":
self.query_one("#sensor-comment-input", Input).focus()
else:
self._dismiss_with_values()

15
tests/conftest.py Normal file
View File

@@ -0,0 +1,15 @@
import os
import sys
from unittest.mock import MagicMock
# Mock environment variables required by config.py
os.environ["MQTT_HOST"] = "localhost"
os.environ["MQTT_PORT"] = "1883"
os.environ["MQTT_USER"] = "user"
os.environ["MQTT_PASSWORD"] = "password"
# Mock bluetooth package globally for all tests
mock_bluetooth = MagicMock()
mock_bluez = MagicMock()
sys.modules["bluetooth"] = mock_bluetooth
sys.modules["bluetooth._bluetooth"] = mock_bluez

98
tests/test_config.py Normal file
View File

@@ -0,0 +1,98 @@
import os
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
import sensorpajen.config as config
def test_config_defaults():
assert config.MQTT_HOST == "localhost"
assert config.MQTT_PORT == 1883
assert config.MQTT_USER == "user"
assert config.MQTT_PASSWORD == "password"
assert config.MQTT_CLIENT_ID == "sensorpajen"
def test_sensor_config_load(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
sensors_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room"},
{"mac": "11:22:33:44:55:66", "name": "Kitchen"}
]
}
import json
with open(config_file, "w") as f:
json.dump(sensors_data, f)
sensor_cfg = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg.sensors == {
"AA:BB:CC:DD:EE:FF": "Living Room",
"11:22:33:44:55:66": "Kitchen"
}
assert sensor_cfg.get_name("AA:BB:CC:DD:EE:FF") == "Living Room"
assert sensor_cfg.get_name("UNKNOWN") == "UNKNOWN"
def test_sensor_config_comment_load_and_clear(tmp_path):
import json
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
config_file.write_text(
json.dumps(
{
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room", "comment": "hello"},
]
},
indent=2,
)
)
sensor_cfg = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") == "hello"
# Clear comment explicitly (empty string means remove comment key)
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "")
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") is None
saved = json.loads(config_file.read_text())
assert saved["sensors"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert saved["sensors"][0]["name"] == "Living Room"
assert "comment" not in saved["sensors"][0]
def test_sensor_config_add_remove(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
# Start with empty
with open(config_file, "w") as f:
import json
json.dump({"sensors": []}, f)
sensor_cfg = config.SensorConfig(config_file=str(config_file))
# Add
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "Test comment")
assert sensor_cfg.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") == "Test comment"
# Verify persistence
sensor_cfg2 = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg2.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
assert sensor_cfg2.get_comment("AA:BB:CC:DD:EE:FF") == "Test comment"
# Remove
sensor_cfg.remove_sensor("AA:BB:CC:DD:EE:FF")
assert "AA:BB:CC:DD:EE:FF" not in sensor_cfg.sensors
# Verify persistence
sensor_cfg3 = config.SensorConfig(config_file=str(config_file))
assert "AA:BB:CC:DD:EE:FF" not in sensor_cfg3.sensors
def test_sensor_config_missing_file(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "nonexistent.json"
sensor_cfg = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg.sensors == {}

82
tests/test_db.py Normal file
View File

@@ -0,0 +1,82 @@
import pytest
import sqlite3
import os
from pathlib import Path
from sensorpajen.db import DatabaseManager
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "test_sensors.db"
@pytest.fixture
def db_manager(db_path):
manager = DatabaseManager(str(db_path))
manager.initialize()
return manager
def test_db_initialization(db_path):
manager = DatabaseManager(str(db_path))
manager.initialize()
assert db_path.exists()
# Verify table exists
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='discovered_sensors'")
assert cursor.fetchone() is not None
conn.close()
def test_add_or_update_sensor(db_manager):
mac = "AA:BB:CC:DD:EE:FF"
db_manager.add_or_update_sensor(
mac=mac,
name="ATC_123456",
rssi=-70,
temp=22.5,
humidity=45.0,
battery_percent=100,
battery_voltage=3000
)
sensor = db_manager.get_sensor(mac)
assert sensor["mac"] == mac
assert sensor["name"] == "ATC_123456"
assert sensor["count"] == 1
assert sensor["status"] == "pending"
# Update
db_manager.add_or_update_sensor(
mac=mac,
name="ATC_123456",
rssi=-60,
temp=23.0,
humidity=40.0,
battery_percent=99,
battery_voltage=2900
)
sensor = db_manager.get_sensor(mac)
assert sensor["count"] == 2
assert sensor["rssi"] == -60
assert sensor["last_temp"] == 23.0
def test_update_status(db_manager):
mac = "AA:BB:CC:DD:EE:FF"
db_manager.add_or_update_sensor(mac, "Test", -70, 20, 50, 100, 3000)
db_manager.update_status(mac, "approved")
sensor = db_manager.get_sensor(mac)
assert sensor["status"] == "approved"
def test_get_sensors_by_status(db_manager):
db_manager.add_or_update_sensor("MAC1", "S1", -70, 20, 50, 100, 3000)
db_manager.add_or_update_sensor("MAC2", "S2", -70, 20, 50, 100, 3000)
db_manager.update_status("MAC2", "ignored")
pending = db_manager.get_sensors(status="pending")
assert len(pending) == 1
assert pending[0]["mac"] == "MAC1"
ignored = db_manager.get_sensors(status="ignored")
assert len(ignored) == 1
assert ignored[0]["mac"] == "MAC2"

View File

@@ -0,0 +1,154 @@
import pytest
import os
from pathlib import Path
from sensorpajen.discovery_manager import DiscoveryManager, DiscoveredSensor
class _DummyCompletedProcess:
def __init__(self, returncode: int = 0, stderr: bytes = b""):
self.returncode = returncode
self.stderr = stderr
def test_discovery_manager_init(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
assert db_file.exists()
def test_discovery_manager_add_new(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
mac = "AA:BB:CC:DD:EE:FF"
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
pending = manager.get_pending()
assert len(pending) == 1
sensor = pending[0]
assert sensor.mac == mac
assert sensor.name == "ATC_123456"
assert sensor.sample_reading["temperature"] == 22.5
assert sensor.status == "pending"
assert sensor.count == 1
def test_discovery_manager_update_existing(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
mac = "AA:BB:CC:DD:EE:FF"
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
# Update with new values
manager.add_or_update(mac, "ATC_123456", -60, 23.0, 40, 99, 2.9)
pending = manager.get_pending()
assert len(pending) == 1
sensor = pending[0]
assert sensor.rssi == -60
assert sensor.sample_reading["temperature"] == 23.0
assert sensor.sample_reading["humidity"] == 40.0
assert sensor.count == 2
def test_discovery_manager_persistence(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
mac = "AA:BB:CC:DD:EE:FF"
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
# Create new manager and load from same DB
manager2 = DiscoveryManager(str(db_file))
pending = manager2.get_pending()
assert len(pending) == 1
assert pending[0].mac == mac
assert pending[0].name == "ATC_123456"
def test_send_ntfy_notification_disabled(monkeypatch, tmp_path):
from sensorpajen import discovery_manager as dm_mod
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", False)
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "token")
called = {"run": False}
def _fake_run(*args, **kwargs):
called["run"] = True
return _DummyCompletedProcess(0)
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
manager = dm_mod.DiscoveryManager(str(tmp_path / "dummy.db"))
sensor = dm_mod.DiscoveredSensor(
mac="AA",
name="N",
rssi=-1,
first_seen="now",
last_seen="now",
sample_reading={"temperature": 1, "humidity": 2, "battery_percent": 3},
)
manager.send_ntfy_notification(sensor)
assert called["run"] is False
def test_send_ntfy_notification_missing_token(monkeypatch, tmp_path):
from sensorpajen import discovery_manager as dm_mod
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", True)
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "")
called = {"run": False}
def _fake_run(*args, **kwargs):
called["run"] = True
return _DummyCompletedProcess(0)
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
manager = dm_mod.DiscoveryManager(str(tmp_path / "dummy2.db"))
sensor = dm_mod.DiscoveredSensor(
mac="AA",
name="N",
rssi=-1,
first_seen="now",
last_seen="now",
sample_reading={"temperature": 1, "humidity": 2, "battery_percent": 3},
)
manager.send_ntfy_notification(sensor)
assert called["run"] is False
def test_send_ntfy_notification_message_mentions_tui(monkeypatch, tmp_path):
from sensorpajen import discovery_manager as dm_mod
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", True)
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "token")
monkeypatch.setattr(dm_mod.config, "NTFY_URL", "https://ntfy.sh")
monkeypatch.setattr(dm_mod.config, "NTFY_TOPIC", "sensorpajen")
captured = {"args": None}
def _fake_run(args, capture_output=True, timeout=10):
captured["args"] = args
return _DummyCompletedProcess(0)
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
manager = dm_mod.DiscoveryManager(str(tmp_path / "sensors.db"))
sensor = dm_mod.DiscoveredSensor(
mac="AA:BB",
name="ATC_123",
rssi=-1,
first_seen="2025-01-01T00:00:00",
last_seen="2025-01-01T00:00:00",
sample_reading={"temperature": 10, "humidity": 20, "battery_percent": 30},
)
manager.send_ntfy_notification(sensor)
assert captured["args"] is not None
# curl args: [..., "-d", message, url]
assert "-d" in captured["args"]
message = captured["args"][captured["args"].index("-d") + 1]
assert "sensorpajen-tui" in message

View File

@@ -0,0 +1,76 @@
import pytest
from unittest.mock import MagicMock, patch, call
from sensorpajen.mqtt_publisher import MQTTPublisher
import sensorpajen.config as config
@pytest.fixture
def mock_config():
with patch("sensorpajen.config.MQTT_HOST", "localhost"), \
patch("sensorpajen.config.MQTT_PORT", 1883), \
patch("sensorpajen.config.MQTT_USER", "user"), \
patch("sensorpajen.config.MQTT_PASSWORD", "pass"), \
patch("sensorpajen.config.MQTT_CLIENT_ID", "test_client"), \
patch("sensorpajen.config.MQTT_TOPIC_PREFIX", "test"):
yield
@pytest.fixture
def mock_config():
with patch("sensorpajen.config.MQTT_HOST", "localhost"), \
patch("sensorpajen.config.MQTT_PORT", 1883), \
patch("sensorpajen.config.MQTT_USER", "user"), \
patch("sensorpajen.config.MQTT_PASSWORD", "pass"), \
patch("sensorpajen.config.MQTT_CLIENT_ID", "test_client"), \
patch("sensorpajen.config.MQTT_TOPIC_PREFIX", "test"):
yield
def test_mqtt_publisher_init(mock_config):
with patch("paho.mqtt.client.Client") as mock_client:
publisher = MQTTPublisher()
mock_client.assert_called_once()
publisher.client.username_pw_set.assert_called_with("user", "pass")
def test_mqtt_publisher_connect(mock_config):
with patch("paho.mqtt.client.Client") as mock_client:
publisher = MQTTPublisher()
publisher.connect()
publisher.client.connect.assert_called_with("localhost", 1883, keepalive=60)
publisher.client.loop_start.assert_called_once()
def test_mqtt_publisher_publish(mock_config):
with patch("paho.mqtt.client.Client") as mock_client:
publisher = MQTTPublisher()
publisher.connected = True
with patch("sensorpajen.config.ENABLE_BATTERY", True):
publisher.publish_measurement("living_room", 22.5, 45, 3.0, 100)
# Check if publish was called for each metric
calls = [
call("test/living_room/temp", "22.5"),
call("test/living_room/humidity", "45"),
call("test/living_room/batteryvoltage", "3.000"),
call("test/living_room/batterylevel", "100")
]
publisher.client.publish.assert_has_calls(calls, any_order=True)
def test_mqtt_publisher_publish_no_battery(mock_config):
with patch("paho.mqtt.client.Client") as mock_client:
publisher = MQTTPublisher()
publisher.connected = True
with patch("sensorpajen.config.ENABLE_BATTERY", False):
publisher.publish_measurement("living_room", 22.5, 45, 3.0, 100)
# Should only publish temp and humidity
assert publisher.client.publish.call_count == 2
publisher.client.publish.assert_any_call("test/living_room/temp", "22.5")
publisher.client.publish.assert_any_call("test/living_room/humidity", "45")
def test_mqtt_publisher_not_connected(mock_config):
with patch("paho.mqtt.client.Client") as mock_client:
publisher = MQTTPublisher()
publisher.connected = False
# Should not raise error, just log warning
publisher.publish_measurement("living_room", 22.5, 45)
publisher.client.publish.assert_not_called()

View File

@@ -0,0 +1,98 @@
import pytest
from unittest.mock import MagicMock, patch, call
import os
import sys
import time
from sensorpajen.sensor_reader import SensorReader, Measurement
from sensorpajen import config
@pytest.fixture
def mock_sensor_config():
cfg = MagicMock(spec=config.SensorConfig)
cfg.sensors = {"AA:BB:CC:DD:EE:FF": "Living Room"}
cfg.get_name.side_effect = lambda mac: cfg.sensors.get(mac.upper(), mac.upper())
return cfg
@pytest.fixture
def mock_discovery_manager():
return MagicMock()
def test_measurement_dataclass():
m = Measurement(temperature=22.5, humidity=45, voltage=3.0, battery=100, sensor_name="Test")
assert m.temperature == 22.5
assert m.humidity == 45
assert m.voltage == 3.0
assert m.battery == 100
assert m.sensor_name == "Test"
def test_sensor_reader_init(mock_sensor_config, mock_discovery_manager):
on_measurement = MagicMock()
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
assert reader.sensor_config == mock_sensor_config
assert reader.on_measurement == on_measurement
assert reader.interface == 0
@patch("sensorpajen.sensor_reader.toggle_device")
@patch("sensorpajen.sensor_reader.enable_le_scan")
@patch("sensorpajen.sensor_reader.parse_le_advertising_events")
def test_sensor_reader_start(mock_parse, mock_enable, mock_toggle, mock_sensor_config, mock_discovery_manager):
on_measurement = MagicMock()
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
# Mock bluez.hci_open_dev where it's used in sensor_reader
with patch("sensorpajen.sensor_reader.bluez.hci_open_dev", return_value=123):
# We need to stop the blocking call to parse_le_advertising_events
mock_parse.side_effect = KeyboardInterrupt()
reader.start()
mock_toggle.assert_called_with(0, True)
mock_enable.assert_called_with(123, filter_duplicates=False)
mock_parse.assert_called_once()
@patch("sensorpajen.sensor_reader.raw_packet_to_str")
def test_handle_ble_packet_known_sensor(mock_raw_to_str, mock_sensor_config, mock_discovery_manager):
on_measurement = MagicMock()
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
# Mock data
mac = "AA:BB:CC:DD:EE:FF"
data = b"\x00" * 20
# ATC packet format: ... 1A18 AABBCCDDEEFF ...
# data_str[6:10] == "1A18"
# data_str[10:22] == "AABBCCDDEEFF"
mock_raw_to_str.return_value = "0000001A18AABBCCDDEEFF000000000000"
# Mock _parse_atc_data
with patch.object(reader, "_parse_atc_data") as mock_parse_atc:
mock_parse_atc.return_value = (22.5, 45, 100, 3.0, "123")
reader._handle_ble_packet(mac, 0, data, -70)
on_measurement.assert_called_once()
measurement = on_measurement.call_args[0][0]
assert measurement.temperature == 22.5
assert measurement.humidity == 45
assert measurement.sensor_name == "Living Room"
@patch("sensorpajen.sensor_reader.raw_packet_to_str")
def test_handle_ble_packet_unknown_sensor(mock_raw_to_str, mock_sensor_config, mock_discovery_manager):
on_measurement = MagicMock()
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
# Mock data for unknown sensor
mac = "11:22:33:44:55:66"
data = b"\x00" * 20
mock_raw_to_str.return_value = "0000001A18112233445566000000000000"
with patch.object(reader, "_parse_atc_data") as mock_parse_atc:
mock_parse_atc.return_value = (20.0, 50, 80, 2.8, "456")
with patch.object(reader, "_handle_unknown_sensor") as mock_handle_unknown:
reader._handle_ble_packet(mac, 0, data, -80)
mock_handle_unknown.assert_called_once_with(
"11:22:33:44:55:66", -80, 20.0, 50, 80, 2.8
)
on_measurement.assert_not_called()

225
tests/test_tui.py Normal file
View File

@@ -0,0 +1,225 @@
import pytest
import tempfile
import json
import sqlite3
from pathlib import Path
from datetime import datetime
from sensorpajen.config import SensorConfig
from sensorpajen.discovery_manager import DiscoveryManager
def test_tui_sensor_config_edit():
"""Integration test: Test that editing a sensor works end-to-end"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
db_file = Path(tmpdir) / "test.db"
# Create initial config
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room Sensor"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Initialize database
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovered_sensors (
mac TEXT PRIMARY KEY,
name TEXT,
rssi INTEGER,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
count INTEGER DEFAULT 0,
last_temp REAL,
last_humidity REAL,
last_battery_percent INTEGER,
last_battery_voltage INTEGER,
status TEXT DEFAULT 'pending',
reviewed BOOLEAN DEFAULT 0,
ignored_at TIMESTAMP,
ignore_reason TEXT
)
""")
now = datetime.now().isoformat()
cursor.execute("""
INSERT INTO discovered_sensors
(mac, name, rssi, first_seen, last_seen, count, last_temp, last_humidity,
last_battery_percent, last_battery_voltage, status, reviewed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', 1)
""", ("AA:BB:CC:DD:EE:FF", "Living Room Sensor", -65, now, now, 50, 23.5, 55, 85, 2950))
conn.commit()
conn.close()
# Load config and discovery manager (simulating TUI)
config = SensorConfig(str(config_file))
dm = DiscoveryManager(str(db_file), config)
# Verify initial state
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room Sensor"
# Edit sensor (simulate user action in TUI)
config.add_sensor("AA:BB:CC:DD:EE:FF", "Bedroom Sensor")
# Verify in-memory update
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Bedroom Sensor"
# Verify disk update
saved_data = json.loads(config_file.read_text())
assert saved_data["sensors"][0]["name"] == "Bedroom Sensor"
# Simulate refresh_data() - create new config instance and verify
config2 = SensorConfig(str(config_file))
assert config2.sensors["AA:BB:CC:DD:EE:FF"] == "Bedroom Sensor"
def test_sensor_config_edit_updates_memory():
"""Test that editing a sensor updates both disk and memory"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
# Create initial config
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Original Name"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Load config
config = SensorConfig(str(config_file))
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Original Name"
# Edit sensor
config.add_sensor("AA:BB:CC:DD:EE:FF", "Updated Name")
# Check in-memory is updated
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Updated Name"
# Check disk is updated
saved_data = json.loads(config_file.read_text())
assert saved_data["sensors"][0]["name"] == "Updated Name"
# Reload from disk and verify
config2 = SensorConfig(str(config_file))
assert config2.sensors["AA:BB:CC:DD:EE:FF"] == "Updated Name"
def test_sensor_config_remove_sensor():
"""Test that removing a sensor works correctly"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
# Create config with multiple sensors
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Sensor 1"},
{"mac": "AA:BB:CC:DD:EE:11", "name": "Sensor 2"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Load and verify
config = SensorConfig(str(config_file))
assert len(config.sensors) == 2
# Remove one sensor
config.remove_sensor("AA:BB:CC:DD:EE:FF")
# Check in-memory removal
assert "AA:BB:CC:DD:EE:FF" not in config.sensors
assert "AA:BB:CC:DD:EE:11" in config.sensors
# Check disk update
saved_data = json.loads(config_file.read_text())
assert len(saved_data["sensors"]) == 1
assert saved_data["sensors"][0]["mac"] == "AA:BB:CC:DD:EE:11"
def test_sensor_config_reload():
"""Test that reload() re-reads from disk"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
# Create initial config
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Original Name"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Load config
config = SensorConfig(str(config_file))
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Original Name"
# Manually modify file on disk
new_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Externally Modified"}
]
}
config_file.write_text(json.dumps(new_data, indent=2))
# Reload should pick up the changes
config.load()
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Externally Modified"
def test_discovery_manager_approve_sensor():
"""Test that approving a sensor works correctly"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
db_file = Path(tmpdir) / "test.db"
# Create empty config
config_file.write_text(json.dumps({"sensors": []}, indent=2))
# Initialize database with pending sensor
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovered_sensors (
mac TEXT PRIMARY KEY,
name TEXT,
rssi INTEGER,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
count INTEGER DEFAULT 0,
last_temp REAL,
last_humidity REAL,
last_battery_percent INTEGER,
last_battery_voltage INTEGER,
status TEXT DEFAULT 'pending',
reviewed BOOLEAN DEFAULT 0,
ignored_at TIMESTAMP,
ignore_reason TEXT
)
""")
now = datetime.now().isoformat()
cursor.execute("""
INSERT INTO discovered_sensors
(mac, name, rssi, first_seen, last_seen, count, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", ("AA:BB:CC:DD:EE:33", "Unknown Sensor", -80, now, now, 1, "pending"))
conn.commit()
conn.close()
# Load config and DM
config = SensorConfig(str(config_file))
dm = DiscoveryManager(str(db_file), config)
# Verify sensor is pending
pending = dm.get_pending()
assert len(pending) == 1
assert pending[0].mac == "AA:BB:CC:DD:EE:33"
# Approve and add to config (simulate TUI action)
config.add_sensor("AA:BB:CC:DD:EE:33", "Kitchen Sensor")
dm.approve("AA:BB:CC:DD:EE:33")
# Verify sensor is no longer pending (filtered by config)
pending = dm.get_pending()
assert len(pending) == 0
# Verify it's in config
assert config.sensors["AA:BB:CC:DD:EE:33"] == "Kitchen Sensor"