From cfa24d1fa526efeee58288a920acbd2c166e487e Mon Sep 17 00:00:00 2001 From: Fredrik Wahlberg Date: Mon, 29 Dec 2025 09:39:33 +0100 Subject: [PATCH] feat: implement Textual TUI and SQLite database for sensor management --- .gitignore | 5 + AGENTS.md | 12 + TASKS.md => COMPLETED_TASKS.md | 84 ++++ ROADMAP-v2.md | 564 ++++++++++++++++++++++++++ ROADMAP.md | 577 ++------------------------- Tasks.md | 15 + pyproject.toml | 4 +- readme.md | 40 +- src/sensorpajen/config.py | 105 ++++- src/sensorpajen/db.py | 114 ++++++ src/sensorpajen/discovery_manager.py | 220 ++++------ src/sensorpajen/migrate_to_db.py | 62 +++ src/sensorpajen/mqtt_publisher.py | 1 + src/sensorpajen/tui/app.py | 218 ++++++++++ src/sensorpajen/tui/modals.py | 33 ++ tests/conftest.py | 15 + tests/test_config.py | 67 ++++ tests/test_db.py | 82 ++++ tests/test_discovery_manager.py | 57 +++ tests/test_mqtt_publisher.py | 76 ++++ tests/test_sensor_reader.py | 98 +++++ tests/test_tui.py | 8 + 22 files changed, 1734 insertions(+), 723 deletions(-) rename TASKS.md => COMPLETED_TASKS.md (87%) create mode 100644 ROADMAP-v2.md create mode 100644 Tasks.md create mode 100644 src/sensorpajen/db.py create mode 100644 src/sensorpajen/migrate_to_db.py create mode 100644 src/sensorpajen/tui/app.py create mode 100644 src/sensorpajen/tui/modals.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 tests/test_db.py create mode 100644 tests/test_discovery_manager.py create mode 100644 tests/test_mqtt_publisher.py create mode 100644 tests/test_sensor_reader.py create mode 100644 tests/test_tui.py diff --git a/.gitignore b/.gitignore index eba731b..2ddc824 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .* __pycache__ temp +*.db +*.egg-info/ +.venv/ +build/ +dist/ diff --git a/AGENTS.md b/AGENTS.md index 1c41afd..990aa17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. --- diff --git a/TASKS.md b/COMPLETED_TASKS.md similarity index 87% rename from TASKS.md rename to COMPLETED_TASKS.md index 706a69d..50874c8 100644 --- a/TASKS.md +++ b/COMPLETED_TASKS.md @@ -1,5 +1,66 @@ # 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) @@ -760,3 +821,26 @@ If you want, I can also: * 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. + diff --git a/ROADMAP-v2.md b/ROADMAP-v2.md new file mode 100644 index 0000000..26dcbfd --- /dev/null +++ b/ROADMAP-v2.md @@ -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 /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 diff --git a/ROADMAP.md b/ROADMAP.md index 26dcbfd..4e03375 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,564 +1,43 @@ -# 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 - -**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 +## 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**: -- 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) +- 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: -- ✅ 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: +- ✅ **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 3: Configuration Migration ✅ DONE (2025-12-27) -**Goal**: Replace .ini file with JSON and environment variables +## Phase 2: Live Monitoring & Global Configuration +**Goal**: Add real-time visibility and full system configuration to the TUI. -**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 +### 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 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 /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) diff --git a/Tasks.md b/Tasks.md new file mode 100644 index 0000000..78f2d69 --- /dev/null +++ b/Tasks.md @@ -0,0 +1,15 @@ +# Tasks + +## 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). diff --git a/pyproject.toml b/pyproject.toml index ae90b78..b290e86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,9 +26,10 @@ classifiers = [ ] dependencies = [ - "pybluez>=0.31", + "pybluez2", "bluepy>=1.3.0", "paho-mqtt>=1.6.0", + "textual>=0.40.0", ] [project.optional-dependencies] @@ -46,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"] diff --git a/readme.md b/readme.md index c8a618d..1838229 100644 --- a/readme.md +++ b/readme.md @@ -65,29 +65,39 @@ sudo nano /etc/sensorpajen/sensorpajen.env sudo systemctl restart sensorpajen ``` -### Approving Sensors (Discovery Workflow) +### Sensor Management (TUI) -The service automatically discovers nearby Bluetooth sensors and stores them in a pending list. You approve which ones to monitor: +The service automatically discovers nearby Bluetooth sensors. You can manage them using the built-in Text UI: ```bash -# Start sensor discovery (if not already running) -sudo systemctl start sensorpajen - -# Let it scan for a minute or two to discover sensors -sleep 120 - -# View discovered sensors and approve them -sudo sensorpajen approve-sensors +# Launch the management TUI +sudo sensorpajen-tui ``` -The approval CLI will: -1. Show newly discovered sensors with their current readings -2. Ask you to approve, ignore, or skip each sensor -3. Save approved sensors to `/etc/sensorpajen/sensors.json` -4. Mark their status in `/var/lib/sensorpajen/discovered_sensors.json` +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 +- `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 + +If you prefer the command line, you can still use: + +```bash +sudo sensorpajen approve-sensors +``` + ### MQTT Settings Edit `config/sensorpajen.env`: diff --git a/src/sensorpajen/config.py b/src/sensorpajen/config.py index c5297a6..d06d039 100644 --- a/src/sensorpajen/config.py +++ b/src/sensorpajen/config.py @@ -9,7 +9,7 @@ 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__) @@ -37,12 +37,13 @@ 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: - raise RuntimeError( - "MQTT_HOST environment variable must be set. " - "Please configure config/sensorpajen.env" - ) +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 SENSOR_CONFIG_FILE = os.environ.get( @@ -70,6 +71,10 @@ DISCOVERED_SENSORS_FILE = os.environ.get( "DISCOVERED_SENSORS_FILE", 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 @@ -132,12 +137,98 @@ class SensorConfig: """Get list of all configured MAC addresses.""" return list(self.sensors.keys()) + 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() + self.sensors[mac] = name + self.save(mac, name, comment) + + 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] + + # 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() + data = {"sensors": []} + + try: + if self.config_file.exists(): + with open(self.config_file, 'r') as f: + data = json.load(f) + + sensors = data.get('sensors', []) + # Update existing or add new + found = False + for s in sensors: + if s.get('mac', '').upper() == mac: + s['name'] = name + if comment: + s['comment'] = comment + found = True + break + + if not found: + new_sensor = {"mac": mac, "name": name} + if 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) + + with open(self.config_file, 'w') as f: + json.dump(data, f, indent=2) + logger.info(f"Saved sensor {mac} to {self.config_file}") + + except Exception as e: + logger.error(f"Error saving sensor config: {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}") diff --git a/src/sensorpajen/db.py b/src/sensorpajen/db.py new file mode 100644 index 0000000..dd3afb6 --- /dev/null +++ b/src/sensorpajen/db.py @@ -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(),)) diff --git a/src/sensorpajen/discovery_manager.py b/src/sensorpajen/discovery_manager.py index 27891dc..ecddeb6 100644 --- a/src/sensorpajen/discovery_manager.py +++ b/src/sensorpajen/discovery_manager.py @@ -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,199 +29,118 @@ 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): """ Initialize discovery manager. Args: - discovery_file: Path to discovered sensors JSON file + db_path: Path to SQLite database file """ - self.discovery_file = Path(discovery_file) - self.sensors: Dict[str, DiscoveredSensor] = {} - self.load() - - 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}") + self.db = DatabaseManager(db_path) + self.db.initialize() + 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, battery_percent: int, battery_voltage: int) -> bool: """ 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 - } + self.db.add_or_update_sensor( + mac=mac, + name=name, + rssi=rssi, + temp=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( - mac=mac, - name=name, - rssi=rssi, - first_seen=now, - last_seen=now, - sample_reading=sample_reading, - status="pending" - ) - self.sensors[mac] = sensor - self.save() + if not existing: logger.info(f"New sensor discovered: {mac} ({name})") + # Send notification for new sensors + sensor = self._row_to_sensor(self.db.get_sensor(mac)) + self.send_ntfy_notification(sensor) 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() - logger.info(f"Sensor approved: {mac}") + """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() - logger.info(f"Sensor ignored: {mac}") + """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") + return [self._row_to_sensor(r) for r in rows] 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 diff --git a/src/sensorpajen/migrate_to_db.py b/src/sensorpajen/migrate_to_db.py new file mode 100644 index 0000000..7ab6fa7 --- /dev/null +++ b/src/sensorpajen/migrate_to_db.py @@ -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() diff --git a/src/sensorpajen/mqtt_publisher.py b/src/sensorpajen/mqtt_publisher.py index 4cabdbd..f16c481 100644 --- a/src/sensorpajen/mqtt_publisher.py +++ b/src/sensorpajen/mqtt_publisher.py @@ -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() diff --git a/src/sensorpajen/tui/app.py b/src/sensorpajen/tui/app.py new file mode 100644 index 0000000..06f012b --- /dev/null +++ b/src/sensorpajen/tui/app.py @@ -0,0 +1,218 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable +from textual.containers import Container +from textual import on + +from ..discovery_manager import DiscoveryManager +from ..config import SensorConfig +from .modals import InputModal + +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; + } + """ + + BINDINGS = [ + ("q", "quit", "Quit"), + ("d", "toggle_dark", "Toggle dark mode"), + ("r", "refresh", "Refresh data"), + ("a", "approve", "Approve"), + ("i", "ignore", "Ignore"), + ("e", "edit", "Edit"), + ("u", "unignore", "Unignore"), + ("delete", "remove", "Remove"), + ] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.discovery_manager = DiscoveryManager() + self.sensor_config = SensorConfig() + + 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") + yield Footer() + + def on_mount(self) -> None: + """Handle app mount event.""" + self.refresh_data() + + 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] + + name = await self.push_screen(InputModal("Enter sensor name", initial_value=default_name)) + if name: + self.sensor_config.add_sensor(mac, name) + self.discovery_manager.approve(mac) + self.notify(f"Approved {mac} as {name}") + self.refresh_data() + + 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] + + reason = await self.push_screen(InputModal("Enter ignore reason (optional)")) + if reason is not None: # Allow empty string but not None (Cancel) + self.discovery_manager.ignore(mac, reason if reason else None) + self.notify(f"Ignored {mac}") + self.refresh_data() + + async def action_edit(self) -> None: + """Edit 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] + current_name = row[1] + + name = await self.push_screen(InputModal("Edit sensor name", initial_value=current_name)) + if name: + self.sensor_config.add_sensor(mac, name) + self.notify(f"Updated {mac} to {name}") + self.refresh_data() + + 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] + + self.sensor_config.remove_sensor(mac) + self.notify(f"Removed {mac}") + self.refresh_data() + + 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.""" + self._update_discovery_table() + self._update_configured_table() + self._update_ignored_table() + + 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") + + for mac, name in self.sensor_config.sensors.items(): + table.add_row(mac, name) + + 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 main(): + app = SensorpajenApp() + app.run() + +if __name__ == "__main__": + main() diff --git a/src/sensorpajen/tui/modals.py b/src/sensorpajen/tui/modals.py new file mode 100644 index 0000000..4acaf41 --- /dev/null +++ b/src/sensorpajen/tui/modals.py @@ -0,0 +1,33 @@ +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Input, Label, Button +from textual.containers import Vertical, Horizontal + +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) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ebe8d12 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7818610 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,67 @@ +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_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" + + # Verify persistence + sensor_cfg2 = config.SensorConfig(config_file=str(config_file)) + assert sensor_cfg2.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room" + + # 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 == {} diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..637e1ae --- /dev/null +++ b/tests/test_db.py @@ -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" diff --git a/tests/test_discovery_manager.py b/tests/test_discovery_manager.py new file mode 100644 index 0000000..3cabfac --- /dev/null +++ b/tests/test_discovery_manager.py @@ -0,0 +1,57 @@ +import pytest +import os +from pathlib import Path +from sensorpajen.discovery_manager import DiscoveryManager, DiscoveredSensor + +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" diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py new file mode 100644 index 0000000..b499f1b --- /dev/null +++ b/tests/test_mqtt_publisher.py @@ -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() diff --git a/tests/test_sensor_reader.py b/tests/test_sensor_reader.py new file mode 100644 index 0000000..436c43a --- /dev/null +++ b/tests/test_sensor_reader.py @@ -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() diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..a9f741a --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,8 @@ +import pytest +from sensorpajen.tui.app import SensorpajenApp + +def test_tui_app_init(): + # Just test that we can instantiate it + app = SensorpajenApp() + assert app.discovery_manager is not None + assert app.sensor_config is not None