From cfa24d1fa526efeee58288a920acbd2c166e487e Mon Sep 17 00:00:00 2001 From: Fredrik Wahlberg Date: Mon, 29 Dec 2025 09:39:33 +0100 Subject: [PATCH 1/4] 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 From 54d55cf0f60616c01c5c8d1bdc050f834a2a7f77 Mon Sep 17 00:00:00 2001 From: Fredrik Wahlberg Date: Mon, 29 Dec 2025 12:22:44 +0100 Subject: [PATCH 2/4] Switching editors --- pyproject.toml | 2 +- scripts/dev-remote.sh | 198 +++++++++++++++++++++ src/sensorpajen/config.py | 66 ++++++- src/sensorpajen/discovery_manager.py | 30 +++- src/sensorpajen/main.py | 2 +- src/sensorpajen/sensor_reader.py | 18 +- src/sensorpajen/tui/app.py | 257 +++++++++++++++++++++++---- 7 files changed, 525 insertions(+), 48 deletions(-) create mode 100755 scripts/dev-remote.sh diff --git a/pyproject.toml b/pyproject.toml index b290e86..75bf41c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ - "pybluez2", + "pybluez", "bluepy>=1.3.0", "paho-mqtt>=1.6.0", "textual>=0.40.0", diff --git a/scripts/dev-remote.sh b/scripts/dev-remote.sh new file mode 100755 index 0000000..faf230b --- /dev/null +++ b/scripts/dev-remote.sh @@ -0,0 +1,198 @@ +#!/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' \ + "$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 + if [ -f config/sensorpajen.env ]; then + echo 'Sanitizing 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 + 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 + # Run TUI + python3 -m sensorpajen.tui.app +" + +# Cleanup happens via trap diff --git a/src/sensorpajen/config.py b/src/sensorpajen/config.py index d06d039..a1a80a0 100644 --- a/src/sensorpajen/config.py +++ b/src/sensorpajen/config.py @@ -14,18 +14,28 @@ from typing import Dict, List, Optional logger = logging.getLogger(__name__) # Determine project root and config directory -# Check if running from system installation (/opt/sensorpajen) or development -_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists() -_var_lib_exists = Path('/var/lib/sensorpajen').exists() +# Check if running from system installation (/opt/sensorpajen) +current_file = Path(__file__).resolve() +is_system_install = str(current_file).startswith('/opt/sensorpajen') -if _opt_sensorpajen_exists: +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 (3 levels up from this file: src/sensorpajen/config.py) - PROJECT_ROOT = Path(__file__).parent.parent.parent + # 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 @@ -221,6 +231,50 @@ class SensorConfig: except Exception as e: logger.error(f"Error saving sensor config: {e}") + 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(): diff --git a/src/sensorpajen/discovery_manager.py b/src/sensorpajen/discovery_manager.py index ecddeb6..03312fc 100644 --- a/src/sensorpajen/discovery_manager.py +++ b/src/sensorpajen/discovery_manager.py @@ -35,15 +35,18 @@ class DiscoveredSensor: class DiscoveryManager: """Manages discovered sensors and their approval status using SQLite.""" - def __init__(self, db_path: str = config.DATABASE_FILE): + + def __init__(self, db_path: str = config.DATABASE_FILE, sensor_config: Optional[config.SensorConfig] = None): """ Initialize discovery manager. Args: db_path: Path to SQLite database file + sensor_config: Optional reference to SensorConfig to filter pending list """ self.db = DatabaseManager(db_path) self.db.initialize() + self.sensor_config = sensor_config def _row_to_sensor(self, row: Dict) -> DiscoveredSensor: """Convert database row to DiscoveredSensor object.""" @@ -90,9 +93,18 @@ class DiscoveryManager: 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) + + # 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 @@ -123,7 +135,15 @@ class DiscoveryManager: def get_pending(self) -> List[DiscoveredSensor]: """Get list of sensors with status 'pending'.""" rows = self.db.get_sensors(status="pending") - return [self._row_to_sensor(r) for r in rows] + 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.""" diff --git a/src/sensorpajen/main.py b/src/sensorpajen/main.py index 1fefb81..925b77f 100644 --- a/src/sensorpajen/main.py +++ b/src/sensorpajen/main.py @@ -131,7 +131,7 @@ class Sensorpajen: # 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...") diff --git a/src/sensorpajen/sensor_reader.py b/src/sensorpajen/sensor_reader.py index 91d6add..d2d8ba6 100644 --- a/src/sensorpajen/sensor_reader.py +++ b/src/sensorpajen/sensor_reader.py @@ -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]: """ diff --git a/src/sensorpajen/tui/app.py b/src/sensorpajen/tui/app.py index 06f012b..9c0be7f 100644 --- a/src/sensorpajen/tui/app.py +++ b/src/sensorpajen/tui/app.py @@ -1,10 +1,10 @@ from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable -from textual.containers import Container +from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable, Static, Button +from textual.containers import Container, Horizontal from textual import on from ..discovery_manager import DiscoveryManager -from ..config import SensorConfig +from ..config import SensorConfig, save_env_var from .modals import InputModal class SensorpajenApp(App): @@ -38,6 +38,31 @@ class SensorpajenApp(App): #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; + } """ BINDINGS = [ @@ -53,8 +78,9 @@ class SensorpajenApp(App): def __init__(self, **kwargs): super().__init__(**kwargs) - self.discovery_manager = DiscoveryManager() self.sensor_config = SensorConfig() + # Pass sensor_config to discovery manager for filtering + self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config) def compose(self) -> ComposeResult: """Create child widgets for the app.""" @@ -66,8 +92,46 @@ class SensorpajenApp(App): 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() @@ -91,10 +155,13 @@ class SensorpajenApp(App): 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() + try: + self.sensor_config.add_sensor(mac, name) + self.discovery_manager.approve(mac) + self.notify(f"Approved {mac} as {name}") + self.refresh_data() + except Exception as e: + self.notify(f"Error approving sensor: {e}", severity="error") async def action_ignore(self) -> None: """Ignore the selected discovered sensor.""" @@ -110,28 +177,55 @@ class SensorpajenApp(App): 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() + 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") 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] + """Edit the selected item (sensor or setting).""" + active_tab = self.query_one(TabbedContent).active - 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() + 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 = row[0] + current_name = row[1] + + name = await self.push_screen(InputModal("Edit sensor name", initial_value=current_name)) + if name: + try: + self.sensor_config.add_sensor(mac, name) + self.notify(f"Updated {mac} to {name}") + self.refresh_data() + except Exception as e: + self.notify(f"Error updating sensor: {e}", severity="error") + + 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] + + new_value = await self.push_screen(InputModal(f"Edit {key}", initial_value=str(current_value))) + if new_value is not None: + try: + save_env_var(key, new_value) + self.notify(f"Updated {key}. Restart required!", severity="warning") + # Temporarily update the view although it won't take effect until restart + import os + os.environ[key] = new_value # Update current runtime env for display + self.refresh_data() + except Exception as e: + self.notify(f"Error saving setting: {e}", severity="error") def action_remove(self) -> None: """Remove the selected configured sensor.""" @@ -145,9 +239,16 @@ class SensorpajenApp(App): 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() + try: + self.sensor_config.remove_sensor(mac) + + # Also need to reset its status in DiscoveryManager to make it show up in Discovery again + self.discovery_manager.unignore(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") def action_unignore(self) -> None: """Unignore the selected sensor.""" @@ -170,6 +271,8 @@ class SensorpajenApp(App): self._update_discovery_table() self._update_configured_table() self._update_ignored_table() + self._update_settings_table() + self._update_dashboard() def _update_discovery_table(self) -> None: table = self.query_one("#discovery-table", DataTable) @@ -191,10 +294,35 @@ class SensorpajenApp(App): def _update_configured_table(self) -> None: table = self.query_one("#configured-table", DataTable) table.clear(columns=True) - table.add_columns("MAC", "Name") + table.add_columns("MAC", "Name", "Temp", "Humidity", "Battery", "RSSI", "Last Seen") for mac, name in self.sensor_config.sensors.items(): - table.add_row(mac, name) + 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) @@ -210,6 +338,71 @@ class SensorpajenApp(App): 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() From fcaaf29307841039d33c64e956d3d21b830a0a52 Mon Sep 17 00:00:00 2001 From: Fredrik Wahlberg Date: Mon, 29 Dec 2025 15:34:03 +0100 Subject: [PATCH 3/4] 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 --- .gitignore | 21 +- ROADMAP.md | 1 + Tasks.md | 9 + VERSION | 2 +- debian/README.md | 19 +- debian/changelog | 9 + debian/debhelper-build-stamp | 1 - debian/files | 1 - debian/install | 2 + debian/postinst | 20 +- debian/sensorpajen-tui | 12 + debian/sensorpajen.postrm.debhelper | 12 - debian/sensorpajen.substvars | 2 - debian/sensorpajen/DEBIAN/control | 20 - debian/sensorpajen/DEBIAN/md5sums | 19 - debian/sensorpajen/DEBIAN/postinst | 151 ------- debian/sensorpajen/DEBIAN/postrm | 41 -- debian/sensorpajen/DEBIAN/prerm | 27 -- .../opt/sensorpajen/pyproject.toml | 65 --- .../opt/sensorpajen/requirements.txt | 3 - .../sensorpajen/scripts/approve-sensors.sh | 48 -- .../sensorpajen/src/sensorpajen/__init__.py | 10 - .../src/sensorpajen/approve_sensors.py | 305 ------------- .../opt/sensorpajen/src/sensorpajen/config.py | 160 ------- .../src/sensorpajen/discovery_manager.py | 263 ----------- .../opt/sensorpajen/src/sensorpajen/main.py | 226 ---------- .../src/sensorpajen/mqtt_publisher.py | 131 ------ .../src/sensorpajen/sensor_reader.py | 292 ------------ .../opt/sensorpajen/src/sensorpajen/utils.py | 421 ------------------ .../lib/systemd/system/sensorpajen.service | 32 -- .../usr/share/doc/sensorpajen/INSTALL.md.gz | Bin 1768 -> 0 bytes .../usr/share/doc/sensorpajen/ROADMAP.md.gz | Bin 5630 -> 0 bytes .../share/doc/sensorpajen/changelog.Debian.gz | Bin 271 -> 0 bytes .../examples/discovered_sensors.json.example | 32 -- .../examples/sensorpajen.env.example | 29 -- .../sensorpajen/examples/sensors.json.example | 37 -- .../usr/share/doc/sensorpajen/readme.md.gz | Bin 2143 -> 0 bytes pyproject.toml | 4 +- readme.md | 24 +- scripts/dev-remote.sh | 10 +- scripts/verify-deb.sh | 2 +- src/sensorpajen/__init__.py | 2 +- src/sensorpajen/config.py | 56 ++- src/sensorpajen/discovery_manager.py | 2 +- src/sensorpajen/main.py | 2 +- src/sensorpajen/tui/app.py | 391 +++++++++++++--- src/sensorpajen/tui/modals.py | 111 ++++- tests/test_config.py | 31 ++ tests/test_discovery_manager.py | 97 ++++ tests/test_tui.py | 229 +++++++++- 50 files changed, 963 insertions(+), 2421 deletions(-) delete mode 100644 debian/debhelper-build-stamp delete mode 100644 debian/files create mode 100755 debian/sensorpajen-tui delete mode 100644 debian/sensorpajen.postrm.debhelper delete mode 100644 debian/sensorpajen.substvars delete mode 100644 debian/sensorpajen/DEBIAN/control delete mode 100644 debian/sensorpajen/DEBIAN/md5sums delete mode 100755 debian/sensorpajen/DEBIAN/postinst delete mode 100755 debian/sensorpajen/DEBIAN/postrm delete mode 100755 debian/sensorpajen/DEBIAN/prerm delete mode 100644 debian/sensorpajen/opt/sensorpajen/pyproject.toml delete mode 100644 debian/sensorpajen/opt/sensorpajen/requirements.txt delete mode 100755 debian/sensorpajen/opt/sensorpajen/scripts/approve-sensors.sh delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/__init__.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/approve_sensors.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/config.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/discovery_manager.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/main.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/mqtt_publisher.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/sensor_reader.py delete mode 100644 debian/sensorpajen/opt/sensorpajen/src/sensorpajen/utils.py delete mode 100644 debian/sensorpajen/usr/lib/systemd/system/sensorpajen.service delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/INSTALL.md.gz delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/ROADMAP.md.gz delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/changelog.Debian.gz delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/examples/discovered_sensors.json.example delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensorpajen.env.example delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensors.json.example delete mode 100644 debian/sensorpajen/usr/share/doc/sensorpajen/readme.md.gz diff --git a/.gitignore b/.gitignore index 2ddc824..c6677c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +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/ diff --git a/ROADMAP.md b/ROADMAP.md index 4e03375..13efc6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -41,3 +41,4 @@ This roadmap defines the evolution of Sensorpajen from a CLI-based tool to a ful - ✅ **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) diff --git a/Tasks.md b/Tasks.md index 78f2d69..5ad25a5 100644 --- a/Tasks.md +++ b/Tasks.md @@ -1,5 +1,14 @@ # 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. diff --git a/VERSION b/VERSION index 227cea2..4a36342 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 +3.0.0 diff --git a/debian/README.md b/debian/README.md index f9557ba..fe7a12d 100644 --- a/debian/README.md +++ b/debian/README.md @@ -63,10 +63,10 @@ ls -lh ../sensorpajen_*.deb ## Build Output ``` -../sensorpajen_2.0.0-dev_all.deb # Installable package -../sensorpajen_2.0.0-dev_armhf.build # Build log -../sensorpajen_2.0.0-dev_armhf.buildinfo # Build metadata -../sensorpajen_2.0.0-dev_armhf.changes # Changes file +../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 @@ -135,6 +135,16 @@ sudo systemctl status sensorpajen 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 @@ -142,6 +152,7 @@ sudo journalctl -u sensorpajen -f | 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/` | diff --git a/debian/changelog b/debian/changelog index ac6a8c0..f34e75f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,12 @@ +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 Mon, 29 Dec 2025 12:00:00 +0100 + sensorpajen (2.0.0) stable; urgency=medium * Production release v2.0.0 diff --git a/debian/debhelper-build-stamp b/debian/debhelper-build-stamp deleted file mode 100644 index 87797f2..0000000 --- a/debian/debhelper-build-stamp +++ /dev/null @@ -1 +0,0 @@ -sensorpajen diff --git a/debian/files b/debian/files deleted file mode 100644 index 7eac509..0000000 --- a/debian/files +++ /dev/null @@ -1 +0,0 @@ -sensorpajen_2.0.0-dev_all.deb misc optional diff --git a/debian/install b/debian/install index 1032039..faa1a1d 100644 --- a/debian/install +++ b/debian/install @@ -1,5 +1,7 @@ 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/ diff --git a/debian/postinst b/debian/postinst index 275ec3e..175e399 100755 --- a/debian/postinst +++ b/debian/postinst @@ -87,11 +87,21 @@ case "$1" in echo "Warning: setcap not found (install libcap2-bin package)" fi - # Install systemd service file - if [ -f /opt/sensorpajen/debian/sensorpajen.service ]; then - cp /opt/sensorpajen/debian/sensorpajen.service /etc/systemd/system/ - elif [ -f /usr/share/doc/sensorpajen/sensorpajen.service ]; then - cp /usr/share/doc/sensorpajen/sensorpajen.service /etc/systemd/system/ + # 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 diff --git a/debian/sensorpajen-tui b/debian/sensorpajen-tui new file mode 100755 index 0000000..5910cc9 --- /dev/null +++ b/debian/sensorpajen-tui @@ -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 "$@" diff --git a/debian/sensorpajen.postrm.debhelper b/debian/sensorpajen.postrm.debhelper deleted file mode 100644 index 4580dfe..0000000 --- a/debian/sensorpajen.postrm.debhelper +++ /dev/null @@ -1,12 +0,0 @@ -# Automatically added by dh_installsystemd/13.14.1ubuntu5 -if [ "$1" = remove ] && [ -d /run/systemd/system ] ; then - systemctl --system daemon-reload >/dev/null || true -fi -# End automatically added section -# Automatically added by dh_installsystemd/13.14.1ubuntu5 -if [ "$1" = "purge" ]; then - if [ -x "/usr/bin/deb-systemd-helper" ]; then - deb-systemd-helper purge 'sensorpajen.service' >/dev/null || true - fi -fi -# End automatically added section diff --git a/debian/sensorpajen.substvars b/debian/sensorpajen.substvars deleted file mode 100644 index 978fc8b..0000000 --- a/debian/sensorpajen.substvars +++ /dev/null @@ -1,2 +0,0 @@ -misc:Depends= -misc:Pre-Depends= diff --git a/debian/sensorpajen/DEBIAN/control b/debian/sensorpajen/DEBIAN/control deleted file mode 100644 index 02f23c1..0000000 --- a/debian/sensorpajen/DEBIAN/control +++ /dev/null @@ -1,20 +0,0 @@ -Package: sensorpajen -Version: 2.0.0-dev -Architecture: all -Maintainer: Fredrik -Installed-Size: 112 -Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin -Recommends: mosquitto-clients -Section: misc -Priority: optional -Homepage: https://github.com/yourusername/sensorpajen -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 diff --git a/debian/sensorpajen/DEBIAN/md5sums b/debian/sensorpajen/DEBIAN/md5sums deleted file mode 100644 index bdf247b..0000000 --- a/debian/sensorpajen/DEBIAN/md5sums +++ /dev/null @@ -1,19 +0,0 @@ -3b3c15c00bf48fc519b8fbe507a93a7e opt/sensorpajen/pyproject.toml -0894789523a53bb372980c0906a7d0b5 opt/sensorpajen/requirements.txt -940d73f24eb9f971ce27f9355e3072f3 opt/sensorpajen/scripts/approve-sensors.sh -20eb4f3839b990a530410768897402c0 opt/sensorpajen/src/sensorpajen/__init__.py -3c6c65213de874065f81b7b3d8948c8b opt/sensorpajen/src/sensorpajen/approve_sensors.py -f69225e19918cca05351fa2da8fd7618 opt/sensorpajen/src/sensorpajen/config.py -65c63383dde4f0b249b708f854ec75a3 opt/sensorpajen/src/sensorpajen/discovery_manager.py -7604c2bc0a854d6d43ff0f0646386fc5 opt/sensorpajen/src/sensorpajen/main.py -331bf9b314492acc6ce03896367f3cf6 opt/sensorpajen/src/sensorpajen/mqtt_publisher.py -5f4ea191e35ce092f39ec0a4f663cb38 opt/sensorpajen/src/sensorpajen/sensor_reader.py -c8dd8fe8fc174a9cd35251fdf80e7b5f opt/sensorpajen/src/sensorpajen/utils.py -b9ad3ea8307d8ed8e938da37ad00f229 usr/lib/systemd/system/sensorpajen.service -4ddb9618c940286f91df901ec818959a usr/share/doc/sensorpajen/INSTALL.md.gz -bd2f1371c60af415bc9d0dbc1111184d usr/share/doc/sensorpajen/ROADMAP.md.gz -380e8e6b01b757ceac05bc5805844ae4 usr/share/doc/sensorpajen/changelog.Debian.gz -14152a98d7cd7fe8daf280aacc4cbf3f usr/share/doc/sensorpajen/examples/discovered_sensors.json.example -74c99b732363f93f0a1c134e1a8c3d35 usr/share/doc/sensorpajen/examples/sensorpajen.env.example -292efbddd951c39cb2c9546d5fac5e05 usr/share/doc/sensorpajen/examples/sensors.json.example -5f647c63bfc3b174611694779fd215e0 usr/share/doc/sensorpajen/readme.md.gz diff --git a/debian/sensorpajen/DEBIAN/postinst b/debian/sensorpajen/DEBIAN/postinst deleted file mode 100755 index 275ec3e..0000000 --- a/debian/sensorpajen/DEBIAN/postinst +++ /dev/null @@ -1,151 +0,0 @@ -#!/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 - - # Install systemd service file - if [ -f /opt/sensorpajen/debian/sensorpajen.service ]; then - cp /opt/sensorpajen/debian/sensorpajen.service /etc/systemd/system/ - elif [ -f /usr/share/doc/sensorpajen/sensorpajen.service ]; then - cp /usr/share/doc/sensorpajen/sensorpajen.service /etc/systemd/system/ - 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 diff --git a/debian/sensorpajen/DEBIAN/postrm b/debian/sensorpajen/DEBIAN/postrm deleted file mode 100755 index 6c350f9..0000000 --- a/debian/sensorpajen/DEBIAN/postrm +++ /dev/null @@ -1,41 +0,0 @@ -#!/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 diff --git a/debian/sensorpajen/DEBIAN/prerm b/debian/sensorpajen/DEBIAN/prerm deleted file mode 100755 index c00341b..0000000 --- a/debian/sensorpajen/DEBIAN/prerm +++ /dev/null @@ -1,27 +0,0 @@ -#!/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 diff --git a/debian/sensorpajen/opt/sensorpajen/pyproject.toml b/debian/sensorpajen/opt/sensorpajen/pyproject.toml deleted file mode 100644 index 9e77055..0000000 --- a/debian/sensorpajen/opt/sensorpajen/pyproject.toml +++ /dev/null @@ -1,65 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "sensorpajen" -version = "2.0.0-dev" -description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC" -readme = "README.md" -requires-python = ">=3.9" -license = {text = "MIT"} -authors = [ - {name = "Fredrik", email = "your@email.com"} -] -keywords = ["bluetooth", "temperature", "sensor", "mqtt", "raspberry-pi"] -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Operating System :: POSIX :: Linux", - "Topic :: Home Automation", -] - -dependencies = [ - "pybluez>=0.31", - "bluepy>=1.3.0", - "paho-mqtt>=1.6.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0", - "pytest-cov>=4.0", - "black>=23.0", - "ruff>=0.1.0", -] - -[project.urls] -Homepage = "https://github.com/yourusername/sensorpajen" -Repository = "https://github.com/yourusername/sensorpajen" - -[project.scripts] -sensorpajen = "sensorpajen.main:main" -sensorpajen-approve-sensors = "sensorpajen.approve_sensors:main" - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.black] -line-length = 100 -target-version = ["py39", "py310", "py311"] - -[tool.ruff] -line-length = 100 -target-version = "py39" - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] diff --git a/debian/sensorpajen/opt/sensorpajen/requirements.txt b/debian/sensorpajen/opt/sensorpajen/requirements.txt deleted file mode 100644 index 696a541..0000000 --- a/debian/sensorpajen/opt/sensorpajen/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pybluez -bluepy -paho-mqtt diff --git a/debian/sensorpajen/opt/sensorpajen/scripts/approve-sensors.sh b/debian/sensorpajen/opt/sensorpajen/scripts/approve-sensors.sh deleted file mode 100755 index 3303a8a..0000000 --- a/debian/sensorpajen/opt/sensorpajen/scripts/approve-sensors.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -# Wrapper script for approve-sensors that works in both dev and system mode - -# Detect installation type -if [ -d "/opt/sensorpajen" ]; then - # System installation - PROJECT_ROOT="/opt/sensorpajen" - VENV_PATH="/opt/sensorpajen/venv" - - # 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" - - # 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 -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 "$@" diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/__init__.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/__init__.py deleted file mode 100644 index d931f51..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Sensorpajen - Bluetooth Temperature Sensor Monitor - -Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors -and publishes data to MQTT broker. -""" - -__version__ = "2.0.0-dev" -__author__ = "Fredrik" -__license__ = "MIT" diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/approve_sensors.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/approve_sensors.py deleted file mode 100644 index 8c56304..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/approve_sensors.py +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env python3 -""" -CLI tool for approving or ignoring discovered sensors. - -Interactive tool to manage pending and ignored sensors. -""" - -import sys -import json -import logging -import argparse -from pathlib import Path -from typing import List - -from . import config -from .discovery_manager import DiscoveryManager, DiscoveredSensor - -logger = logging.getLogger(__name__) - - -def format_metadata_comment(sensor: DiscoveredSensor) -> str: - """ - Format sensor metadata as a comment string. - - Args: - sensor: Discovered sensor - - Returns: - Formatted comment string - """ - return ( - f"MAC: {sensor.mac}, " - f"Name: {sensor.name}, " - f"Last seen: {sensor.last_seen}, " - f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C, " - f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%, " - f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%" - ) - - -def display_sensor(sensor: DiscoveredSensor, index: int, total: int): - """ - Display sensor information to the user. - - Args: - sensor: Discovered sensor to display - index: Current sensor number (1-based) - total: Total number of sensors - """ - print(f"\n{'='*70}") - print(f"Sensor {index}/{total}") - print(f"{'='*70}") - print(f"MAC Address: {sensor.mac}") - print(f"Device Name: {sensor.name}") - print(f"Last Seen: {sensor.last_seen}") - print(f"Status: {sensor.status}") - - if sensor.status == "ignored" and sensor.ignored_at: - print(f"Ignored At: {sensor.ignored_at}") - if sensor.ignore_reason: - print(f"Reason: {sensor.ignore_reason}") - - # Display sample reading - reading = sensor.sample_reading - print(f"\nSample Reading:") - print(f" Temperature: {reading.get('temperature', 'N/A')}°C") - print(f" Humidity: {reading.get('humidity', 'N/A')}%") - print(f" Battery: {reading.get('battery_percent', 'N/A')}%") - print(f" Voltage: {reading.get('battery_voltage', 'N/A')}mV") - print(f"{'='*70}") - - -def get_user_choice() -> str: - """ - Get user's choice for what to do with the sensor. - - Returns: - User choice: 'a' (approve), 'i' (ignore), 's' (skip) - """ - while True: - choice = input("\n[A]pprove, [I]gnore, [S]kip, [Q]uit? ").strip().lower() - if choice in ['a', 'i', 's', 'q']: - return choice - print("Invalid choice. Please enter A, I, S, or Q.") - - -def approve_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager): - """ - Approve a sensor and add it to sensors.json. - - Args: - sensor: Sensor to approve - manager: Discovery manager - """ - # Check if sensor already exists in sensors.json - sensor_config_path = Path(config.SENSOR_CONFIG_FILE) - - try: - with open(sensor_config_path, 'r') as f: - data = json.load(f) - - # Check for duplicates - for existing_sensor in data.get('sensors', []): - if existing_sensor.get('mac', '').upper() == sensor.mac: - print(f"\n⚠️ Sensor {sensor.mac} already exists in sensors.json") - print(" Renaming must be done manually in the file.") - return - - except FileNotFoundError: - # File doesn't exist yet, create with empty sensors list - data = {'sensors': []} - except json.JSONDecodeError as e: - print(f"\n❌ Error: Invalid JSON in {sensor_config_path}: {e}") - return - - # Get sensor name from user - while True: - name = input("\nEnter sensor name (required): ").strip() - if name: - break - print("Sensor name cannot be empty.") - - # Pre-fill comment with metadata - default_comment = format_metadata_comment(sensor) - print(f"\nDefault comment:") - print(f" {default_comment}") - - edit = input("\nEdit comment? [y/N]: ").strip().lower() - if edit == 'y': - print("\nEnter comment (or press Enter to keep default):") - comment = input("> ").strip() - if not comment: - comment = default_comment - else: - comment = default_comment - - # Add to sensors.json - new_sensor = { - "mac": sensor.mac, - "name": name - } - - if comment: - new_sensor["comment"] = comment - - data.setdefault('sensors', []).append(new_sensor) - - try: - with open(sensor_config_path, 'w') as f: - json.dump(data, f, indent=2) - - print(f"\n✅ Sensor approved and added to sensors.json") - print(f" Name: {name}") - print(f" Configuration will be reloaded automatically within 15 minutes") - - # 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}") - - -def ignore_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager): - """ - Ignore a sensor. - - Args: - sensor: Sensor to ignore - manager: Discovery manager - """ - reason = input("\nReason for ignoring (optional): ").strip() - - manager.ignore(sensor.mac, reason if reason else None) - - print(f"\n✅ Sensor ignored and marked in discovered_sensors.json") - if reason: - print(f" Reason: {reason}") - - -def process_sensors(sensors: List[DiscoveredSensor], manager: DiscoveryManager): - """ - Process list of sensors interactively. - - Args: - sensors: List of sensors to process - manager: Discovery manager - """ - if not sensors: - print("\n✅ No sensors to process") - return - - print(f"\nFound {len(sensors)} sensor(s) to review") - - for i, sensor in enumerate(sensors, 1): - # Mark as reviewed when shown - manager.mark_reviewed(sensor.mac) - - display_sensor(sensor, i, len(sensors)) - - choice = get_user_choice() - - if choice == 'q': - print("\n👋 Exiting...") - break - elif choice == 'a': - approve_sensor(sensor, manager) - elif choice == 'i': - ignore_sensor(sensor, manager) - elif choice == 's': - print("\n⏭️ Skipped") - continue - - -def main(): - """Main entry point for approve-sensors CLI.""" - # Parse command line arguments - parser = argparse.ArgumentParser( - description="Approve or ignore discovered Bluetooth sensors", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s # Show only new pending sensors - %(prog)s --all # Show all pending sensors (including reviewed) - %(prog)s --ignored # Show only ignored sensors - %(prog)s --all --ignored # Show all sensors - """ - ) - parser.add_argument( - '--all', '-a', - action='store_true', - help='Show all pending sensors, including previously reviewed ones' - ) - parser.add_argument( - '--ignored', '-i', - action='store_true', - help='Show ignored sensors' - ) - - args = parser.parse_args() - - # Setup logging - logging.basicConfig( - level=logging.WARNING, - format='%(levelname)s: %(message)s' - ) - - print("=" * 70) - print("Sensorpajen - Approve Sensors") - print("=" * 70) - - try: - # Load discovery manager - manager = DiscoveryManager() - - # Get sensors based on flags - if args.all: - pending = manager.get_pending() - pending_label = "all pending" - else: - pending = manager.get_new_pending() - pending_label = "new pending" - - ignored = manager.get_ignored() if args.ignored else [] - - if not pending and not ignored: - if args.all or args.ignored: - print(f"\n✅ No {pending_label if pending else 'ignored'} sensors found") - else: - print("\n✅ No new sensors to review") - all_pending = manager.get_pending() - if all_pending: - print(f"\nThere are {len(all_pending)} previously reviewed pending sensor(s).") - print("Run with --all to review them again.") - return 0 - - # Process pending sensors - if pending: - print(f"\n📋 Processing {len(pending)} {pending_label} sensor(s)...") - process_sensors(pending, manager) - - # Process ignored sensors if requested - if ignored: - if pending: - print("\n" + "=" * 70) - print(f"\n📋 Processing {len(ignored)} ignored sensor(s)...") - process_sensors(ignored, manager) - - print("\n" + "=" * 70) - print("Done!") - print("=" * 70) - - return 0 - - except KeyboardInterrupt: - print("\n\n👋 Interrupted by user") - return 1 - except Exception as e: - logger.error(f"Error: {e}", exc_info=True) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/config.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/config.py deleted file mode 100644 index c5297a6..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/config.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -Configuration management for Sensorpajen. - -Loads configuration from environment variables with sensible defaults. -Configuration files are loaded relative to the project root. -""" - -import os -import json -import logging -from pathlib import Path -from typing import Dict, List - -logger = logging.getLogger(__name__) - -# Determine project root and config directory -# Check if running from system installation (/opt/sensorpajen) or development -_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists() -_var_lib_exists = Path('/var/lib/sensorpajen').exists() - -if _opt_sensorpajen_exists: - # System installation - PROJECT_ROOT = Path('/opt/sensorpajen') - CONFIG_DIR = Path('/etc/sensorpajen') - STATE_DIR = Path('/var/lib/sensorpajen') -else: - # Development installation (3 levels up from this file: src/sensorpajen/config.py) - 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") -MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) -MQTT_USER = os.environ.get("MQTT_USER") -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" - ) - -# Sensor configuration file -SENSOR_CONFIG_FILE = os.environ.get( - "SENSOR_CONFIG_FILE", - str(CONFIG_DIR / "sensors.json") -) - -# Application settings -WATCHDOG_TIMEOUT = int(os.environ.get("WATCHDOG_TIMEOUT", "5")) -ENABLE_BATTERY = os.environ.get("ENABLE_BATTERY", "true").lower() == "true" -LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() - -# Bluetooth settings -SKIP_IDENTICAL = int(os.environ.get("SKIP_IDENTICAL", "50")) -DEBOUNCE = os.environ.get("DEBOUNCE", "true").lower() == "true" - -# ntfy notification settings (optional) -NTFY_ENABLED = os.environ.get("NTFY_ENABLED", "false").lower() == "true" -NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.sh") -NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "sensorpajen") -NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") - -# Discovery settings -DISCOVERED_SENSORS_FILE = os.environ.get( - "DISCOVERED_SENSORS_FILE", - str(STATE_DIR / "discovered_sensors.json") -) -CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes - - -class SensorConfig: - """Manages sensor configuration from JSON file.""" - - def __init__(self, config_file: str = SENSOR_CONFIG_FILE): - """ - Initialize sensor configuration. - - Args: - config_file: Path to sensors JSON configuration file - """ - self.config_file = Path(config_file) - self.sensors: Dict[str, str] = {} - self.load() - - def load(self): - """Load sensor configuration from JSON file.""" - if not self.config_file.exists(): - logger.warning( - f"Sensor configuration file not found: {self.config_file}\n" - f"Starting with no sensors - use discovery to add sensors" - ) - return - - try: - with open(self.config_file, 'r') as f: - data = json.load(f) - - # Convert sensors list to MAC -> name mapping - for sensor in data.get('sensors', []): - mac = sensor.get('mac', '').upper() - name = sensor.get('name') - - if mac and name: - self.sensors[mac] = name - logger.debug(f"Loaded sensor: {mac} -> {name}") - - logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}") - - except json.JSONDecodeError as e: - raise RuntimeError(f"Invalid JSON in {self.config_file}: {e}") - except Exception as e: - raise RuntimeError(f"Error loading sensor config: {e}") - - def get_name(self, mac: str) -> str: - """ - Get sensor name by MAC address. - - Args: - mac: MAC address (any case) - - Returns: - Sensor name or the MAC address if not found - """ - return self.sensors.get(mac.upper(), mac) - - def get_all_macs(self) -> List[str]: - """Get list of all configured MAC addresses.""" - return list(self.sensors.keys()) - - -def validate_config(): - """ - Validate configuration and log settings. - Should be called at application startup. - """ - 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}") - logger.info(f"MQTT Topic Prefix: {MQTT_TOPIC_PREFIX}") - logger.info(f"Sensor Config: {SENSOR_CONFIG_FILE}") - logger.info(f"Discovered Sensors: {DISCOVERED_SENSORS_FILE}") - logger.info(f"Watchdog Timeout: {WATCHDOG_TIMEOUT}s") - logger.info(f"Battery Monitoring: {ENABLE_BATTERY}") - logger.info(f"Config Reload Interval: {CONFIG_RELOAD_INTERVAL}s") - logger.info(f"ntfy Enabled: {NTFY_ENABLED}") - if NTFY_ENABLED: - logger.info(f"ntfy URL: {NTFY_URL}/{NTFY_TOPIC}") - logger.info(f"Log Level: {LOG_LEVEL}") - logger.info("================================") diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/discovery_manager.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/discovery_manager.py deleted file mode 100644 index 27891dc..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/discovery_manager.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -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 . import config - -logger = logging.getLogger(__name__) - - -@dataclass -class DiscoveredSensor: - """Represents a discovered sensor with metadata.""" - mac: str - name: str - rssi: int - first_seen: str - last_seen: str - sample_reading: Dict[str, float] - status: str = "pending" # pending, approved, ignored - reviewed: bool = False # Has been shown in approval CLI - ignored_at: Optional[str] = None - ignore_reason: Optional[str] = None - - -class DiscoveryManager: - """Manages discovered sensors and their approval status.""" - - def __init__(self, discovery_file: str = config.DISCOVERED_SENSORS_FILE): - """ - Initialize discovery manager. - - Args: - discovery_file: Path to discovered sensors JSON 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}") - - 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() - - 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( - mac=mac, - name=name, - rssi=rssi, - first_seen=now, - last_seen=now, - sample_reading=sample_reading, - status="pending" - ) - self.sensors[mac] = sensor - self.save() - logger.info(f"New sensor discovered: {mac} ({name})") - return True - - 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 - - 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 - - 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}") - - 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}") - - 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"] - - 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] - - 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"] - - 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() - - def send_ntfy_notification(self, sensor: DiscoveredSensor): - """ - Send ntfy notification for a newly discovered sensor. - - Args: - sensor: Discovered sensor to notify about - """ - if not config.NTFY_ENABLED: - logger.debug("ntfy notifications disabled") - return - - if not config.NTFY_TOKEN: - logger.warning("ntfy enabled but NTFY_TOKEN not set") - return - - try: - message = ( - f"🆕 New sensor discovered!\n\n" - f"MAC: {sensor.mac}\n" - f"Name: {sensor.name}\n" - f"Last seen: {sensor.last_seen}\n" - 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." - ) - - url = f"{config.NTFY_URL}/{config.NTFY_TOPIC}" - - result = subprocess.run( - ["curl", "-H", f"Authorization: Bearer {config.NTFY_TOKEN}", - "-d", message, url], - capture_output=True, - timeout=10 - ) - - if result.returncode == 0: - logger.info(f"Sent ntfy notification for {sensor.mac}") - else: - logger.warning(f"ntfy notification failed: {result.stderr.decode()}") - - except subprocess.TimeoutExpired: - logger.warning("ntfy notification timed out") - except Exception as e: - logger.error(f"Error sending ntfy notification: {e}") diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/main.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/main.py deleted file mode 100644 index 1fefb81..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/main.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env python3 -""" -Sensorpajen - Main entry point - -Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC sensors. -Publishes sensor data to MQTT broker. -""" - -import sys -import signal -import logging -import time -import threading -from pathlib import Path - -from . import __version__ -from . import config -from .mqtt_publisher import MQTTPublisher -from .sensor_reader import SensorReader, Measurement -from .discovery_manager import DiscoveryManager - - -class Sensorpajen: - """Main application class.""" - - def __init__(self): - """Initialize the application.""" - self.mqtt_publisher: MQTTPublisher = None - self.sensor_reader: SensorReader = None - self.sensor_config: config.SensorConfig = None - self.discovery_manager: DiscoveryManager = None - self.running = False - self.config_reload_timer: threading.Timer = None - - # Setup logging - self._setup_logging() - - # Setup signal handlers - signal.signal(signal.SIGTERM, self._signal_handler) - signal.signal(signal.SIGINT, self._signal_handler) - - def _setup_logging(self): - """Configure logging to stdout for journald.""" - log_level = getattr(logging, config.LOG_LEVEL, logging.INFO) - - logging.basicConfig( - level=log_level, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - stream=sys.stdout - ) - - # Set our logger - self.logger = logging.getLogger(__name__) - - def _signal_handler(self, sig, frame): - """Handle shutdown signals.""" - signal_name = "SIGTERM" if sig == signal.SIGTERM else "SIGINT" - self.logger.info(f"Received {signal_name}, shutting down gracefully...") - self.shutdown() - sys.exit(0) - - def _on_measurement(self, measurement: Measurement): - """ - Callback for new sensor measurements. - - Args: - measurement: Sensor measurement data - """ - try: - # Publish to MQTT - self.mqtt_publisher.publish_measurement( - sensor_name=measurement.sensor_name, - temperature=measurement.temperature, - humidity=measurement.humidity, - battery_voltage=measurement.voltage, - battery_level=measurement.battery - ) - except Exception as e: - self.logger.error(f"Error handling measurement: {e}") - - def _reload_config(self): - """Reload sensor configuration periodically.""" - if not self.running: - return - - try: - self.logger.info("Reloading sensor configuration...") - old_sensors = set(self.sensor_config.sensors.keys()) - self.sensor_config.load() - new_sensors = set(self.sensor_config.sensors.keys()) - - added = new_sensors - old_sensors - removed = old_sensors - new_sensors - - if added: - self.logger.info(f"Added sensors: {', '.join(added)}") - if removed: - self.logger.info(f"Removed sensors: {', '.join(removed)}") - if not added and not removed: - self.logger.debug("No sensor configuration changes") - - except Exception as e: - self.logger.error(f"Error reloading configuration: {e}") - finally: - # Schedule next reload - if self.running: - self.config_reload_timer = threading.Timer( - config.CONFIG_RELOAD_INTERVAL, - self._reload_config - ) - self.config_reload_timer.daemon = True - self.config_reload_timer.start() - - def start(self): - """Start the application.""" - try: - self.logger.info("=" * 50) - self.logger.info(f"Starting Sensorpajen v{__version__}") - self.logger.info("=" * 50) - - # Validate and log configuration - config.validate_config() - - # Load sensor configuration - self.sensor_config = config.SensorConfig() - - if len(self.sensor_config.sensors) == 0: - self.logger.warning("No sensors configured") - self.logger.warning("Starting in discovery-only mode") - self.logger.warning("Use 'sensorpajen approve-sensors' to add sensors") - - # Initialize discovery manager - self.logger.info("Initializing discovery manager...") - self.discovery_manager = DiscoveryManager() - - # Initialize MQTT publisher - self.logger.info("Initializing MQTT publisher...") - self.mqtt_publisher = MQTTPublisher() - self.mqtt_publisher.connect() - - # Wait a moment for MQTT connection - time.sleep(1) - - if not self.mqtt_publisher.is_connected(): - self.logger.warning("MQTT connection not established yet, continuing anyway...") - - # Initialize sensor reader - self.logger.info("Initializing Bluetooth sensor reader...") - self.sensor_reader = SensorReader( - sensor_config=self.sensor_config, - discovery_manager=self.discovery_manager, - on_measurement=self._on_measurement, - interface=0 # hci0 - ) - - # Start config reload timer - self.config_reload_timer = threading.Timer( - config.CONFIG_RELOAD_INTERVAL, - self._reload_config - ) - self.config_reload_timer.daemon = True - self.config_reload_timer.start() - self.logger.info(f"Config reload scheduled every {config.CONFIG_RELOAD_INTERVAL}s") - - # Start reading sensors (blocking call) - self.logger.info("=" * 50) - self.logger.info("Sensorpajen is now running") - self.logger.info("Monitoring sensors via Bluetooth...") - self.logger.info("Publishing to MQTT...") - self.logger.info("Press Ctrl+C to stop") - self.logger.info("=" * 50) - - self.running = True - self.sensor_reader.start() - - except FileNotFoundError as e: - self.logger.error(f"Configuration error: {e}") - sys.exit(1) - except RuntimeError as e: - self.logger.error(f"Configuration error: {e}") - sys.exit(1) - except Exception as e: - self.logger.error(f"Failed to start application: {e}", exc_info=True) - self.shutdown() - sys.exit(1) - - def shutdown(self): - """Shutdown the application gracefully.""" - if not self.running: - return - - self.running = False - self.logger.info("Shutting down...") - - # Cancel config reload timer - if self.config_reload_timer: - try: - self.config_reload_timer.cancel() - except Exception as e: - self.logger.error(f"Error canceling reload timer: {e}") - - # Stop sensor reader - if self.sensor_reader: - try: - self.sensor_reader.stop() - except Exception as e: - self.logger.error(f"Error stopping sensor reader: {e}") - - # Disconnect MQTT - if self.mqtt_publisher: - try: - self.mqtt_publisher.disconnect() - except Exception as e: - self.logger.error(f"Error disconnecting MQTT: {e}") - - self.logger.info("Shutdown complete") - - -def main(): - """Main entry point.""" - app = Sensorpajen() - app.start() - - -if __name__ == "__main__": - main() diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/mqtt_publisher.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/mqtt_publisher.py deleted file mode 100644 index 4cabdbd..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/mqtt_publisher.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -MQTT Publisher for sensor data. - -Handles connection to MQTT broker and publishing of sensor measurements. -""" - -import logging -import paho.mqtt.client as mqtt -from typing import Optional -from . import config - -logger = logging.getLogger(__name__) - - -class MQTTPublisher: - """Manages MQTT connection and publishing of sensor data.""" - - def __init__(self): - """Initialize MQTT publisher with configuration.""" - self.client: Optional[mqtt.Client] = None - self.connected = False - self._setup_client() - - def _setup_client(self): - """Setup MQTT client with callbacks.""" - # Handle both paho-mqtt v1.x and v2.x - try: - # Try v2.x format (with callback_api_version) - self.client = mqtt.Client( - callback_api_version=mqtt.CallbackAPIVersion.VERSION1, - client_id=config.MQTT_CLIENT_ID - ) - except (TypeError, AttributeError): - # Fall back to v1.x format - self.client = mqtt.Client(config.MQTT_CLIENT_ID) - - # Set credentials if provided - if config.MQTT_USER and config.MQTT_PASSWORD: - self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASSWORD) - - # Setup callbacks - self.client.on_connect = self._on_connect - self.client.on_disconnect = self._on_disconnect - self.client.on_publish = self._on_publish - - logger.info(f"MQTT client configured for {config.MQTT_HOST}:{config.MQTT_PORT}") - - def _on_connect(self, client, userdata, flags, rc): - """Callback for when client connects to broker.""" - if rc == 0: - self.connected = True - logger.info(f"Connected to MQTT broker at {config.MQTT_HOST}:{config.MQTT_PORT}") - else: - self.connected = False - logger.error(f"Failed to connect to MQTT broker. Return code: {rc}") - - def _on_disconnect(self, client, userdata, rc): - """Callback for when client disconnects from broker.""" - self.connected = False - if rc != 0: - logger.warning(f"Unexpected disconnection from MQTT broker. Return code: {rc}") - else: - logger.info("Disconnected from MQTT broker") - - def _on_publish(self, client, userdata, mid): - """Callback for when message is published.""" - logger.debug(f"Message published: {mid}") - - def connect(self): - """Connect to MQTT broker.""" - try: - logger.info(f"Connecting to MQTT broker at {config.MQTT_HOST}:{config.MQTT_PORT}") - self.client.connect(config.MQTT_HOST, config.MQTT_PORT, keepalive=60) - self.client.loop_start() # Start network loop in background thread - except Exception as e: - logger.error(f"Failed to connect to MQTT broker: {e}") - raise - - def disconnect(self): - """Disconnect from MQTT broker.""" - if self.client: - self.client.loop_stop() - self.client.disconnect() - logger.info("Disconnected from MQTT broker") - - def publish_measurement(self, sensor_name: str, temperature: float, - humidity: int, battery_voltage: float = None, - battery_level: int = None): - """ - Publish sensor measurement to MQTT. - - Args: - sensor_name: Name of the sensor - temperature: Temperature in Celsius - humidity: Humidity percentage - battery_voltage: Battery voltage (optional) - battery_level: Battery level percentage (optional) - """ - if not self.connected: - logger.warning("Not connected to MQTT broker, skipping publish") - return - - topic_prefix = f"{config.MQTT_TOPIC_PREFIX}/{sensor_name}" - - try: - # Publish temperature - self.client.publish(f"{topic_prefix}/temp", f"{temperature:.1f}") - logger.debug(f"{sensor_name}: temp={temperature:.1f}°C") - - # Publish humidity - self.client.publish(f"{topic_prefix}/humidity", f"{humidity}") - logger.debug(f"{sensor_name}: humidity={humidity}%") - - # Publish battery info if enabled and available - if config.ENABLE_BATTERY: - if battery_voltage is not None: - self.client.publish(f"{topic_prefix}/batteryvoltage", f"{battery_voltage:.3f}") - logger.debug(f"{sensor_name}: battery_voltage={battery_voltage:.3f}V") - - if battery_level is not None: - self.client.publish(f"{topic_prefix}/batterylevel", f"{battery_level}") - logger.debug(f"{sensor_name}: battery_level={battery_level}%") - - logger.info(f"Published: {sensor_name} - {temperature:.1f}°C, {humidity}%") - - except Exception as e: - logger.error(f"Error publishing to MQTT: {e}") - - def is_connected(self) -> bool: - """Check if connected to MQTT broker.""" - return self.connected diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/sensor_reader.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/sensor_reader.py deleted file mode 100644 index 91d6add..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/sensor_reader.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -Bluetooth sensor reader for Xiaomi Mijia LYWSD03MMC sensors with ATC firmware. - -Reads temperature, humidity, and battery data from BLE advertisements. -""" - -import logging -import time -import threading -import bluetooth._bluetooth as bluez -from dataclasses import dataclass -from typing import Optional, Callable, Dict - -from . import config -from .utils import (enable_le_scan, disable_le_scan, - parse_le_advertising_events, raw_packet_to_str, toggle_device) - -logger = logging.getLogger(__name__) - - -@dataclass -class Measurement: - """Sensor measurement data.""" - temperature: float - humidity: int - voltage: float - battery: int = 0 - rssi: int = 0 - sensor_name: str = "" - timestamp: int = 0 - - -class SensorReader: - """Reads Xiaomi LYWSD03MMC sensors with ATC firmware via BLE.""" - - def __init__(self, sensor_config: config.SensorConfig, - discovery_manager, - on_measurement: Callable[[Measurement], None], - interface: int = 0): - """ - Initialize sensor reader. - - Args: - sensor_config: Sensor configuration mapping - discovery_manager: Discovery manager for tracking new sensors - on_measurement: Callback function for new measurements - interface: Bluetooth interface number (default 0 for hci0) - """ - self.sensor_config = sensor_config - self.discovery_manager = discovery_manager - self.on_measurement = on_measurement - self.interface = interface - self.sock: Optional[int] = None - self.running = False - self.last_ble_packet = time.time() - self.adv_counter: Dict[str, str] = {} # Track advertisement numbers to avoid duplicates - self.watchdog_thread: Optional[threading.Thread] = None - - def start(self): - """Start BLE scanning for sensors.""" - try: - logger.info(f"Starting BLE scan on hci{self.interface}") - - # Enable bluetooth device - toggle_device(self.interface, True) - - # Open bluetooth socket - try: - self.sock = bluez.hci_open_dev(self.interface) - except Exception as e: - logger.error(f"Cannot open bluetooth device hci{self.interface}: {e}") - raise - - # Enable LE scanning without filtering duplicates - enable_le_scan(self.sock, filter_duplicates=False) - - # Start watchdog if configured - if config.WATCHDOG_TIMEOUT > 0: - self.running = True - self.watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True) - self.watchdog_thread.start() - logger.info(f"Watchdog started with {config.WATCHDOG_TIMEOUT}s timeout") - - logger.info("BLE scanning enabled") - logger.info(f"Monitoring {len(self.sensor_config.sensors)} sensors") - - # Start parsing advertisements (blocking call) - parse_le_advertising_events( - self.sock, - handler=self._handle_ble_packet, - debug=False - ) - - except KeyboardInterrupt: - logger.info("Received keyboard interrupt") - self.stop() - except Exception as e: - logger.error(f"Error in sensor reader: {e}") - self.stop() - raise - - def stop(self): - """Stop BLE scanning.""" - self.running = False - - if self.sock: - try: - disable_le_scan(self.sock) - logger.info("BLE scanning disabled") - except Exception as e: - logger.error(f"Error disabling BLE scan: {e}") - - if self.watchdog_thread and self.watchdog_thread.is_alive(): - self.watchdog_thread.join(timeout=2) - - def _watchdog_loop(self): - """Watchdog thread to restart BLE scanning if no packets received.""" - restart_counter = 1 - - while self.running: - time.sleep(1) - now = time.time() - elapsed = now - self.last_ble_packet - - if elapsed > config.WATCHDOG_TIMEOUT: - logger.warning( - f"Watchdog: No BLE packet within {int(elapsed)}s. " - f"Restarting BLE scan (count: {restart_counter})" - ) - try: - disable_le_scan(self.sock) - time.sleep(1) - enable_le_scan(self.sock, filter_duplicates=False) - restart_counter += 1 - self.last_ble_packet = now # Reset timer - except Exception as e: - logger.error(f"Error restarting BLE scan: {e}") - - def _handle_ble_packet(self, mac: str, adv_type: int, data: bytes, rssi: int): - """ - Handle incoming BLE advertisement packet. - - Args: - mac: MAC address of the device - adv_type: Advertisement type - data: Advertisement data - rssi: Signal strength - """ - # Update last packet time for watchdog - self.last_ble_packet = time.time() - - # Convert data to hex string - data_str = raw_packet_to_str(data) - - # Check if this is an ATC packet - # ATC format: [... service UUID 0x181A ... MAC ... data ...] - atc_identifier = data_str[6:10].upper() - if atc_identifier != "1A18": - return # Not an ATC packet - - # Extract MAC from packet and verify it matches - packet_mac = data_str[10:22].upper() - mac_str = mac.replace(":", "").upper() - - if packet_mac != mac_str: - return # MAC mismatch - - mac_with_colons = mac.upper() - - # Parse ATC data packet first to get sensor data - try: - parsed_data = self._parse_atc_data(data_str) - if not parsed_data: - return - - temperature, humidity, battery_percent, battery_voltage, adv_number = parsed_data - - # Check if this is a known sensor - if mac_with_colons not in self.sensor_config.sensors: - # Unknown sensor - check if we should discover it - self._handle_unknown_sensor( - mac_with_colons, - rssi, - temperature, - humidity, - battery_percent, - battery_voltage - ) - return - - # Check advertisement number to avoid duplicates - if mac_str in self.adv_counter: - if self.adv_counter[mac_str] == adv_number: - return # Duplicate packet - self.adv_counter[mac_str] = adv_number - - # Create measurement for known sensor - sensor_name = self.sensor_config.get_name(mac_with_colons) - measurement = Measurement( - temperature=temperature, - humidity=humidity, - voltage=battery_voltage / 1000.0, - battery=battery_percent, - rssi=rssi, - sensor_name=sensor_name, - timestamp=int(time.time()) - ) - - # Log the measurement - logger.info( - f"{measurement.sensor_name}: {measurement.temperature}°C, " - f"{measurement.humidity}%, {measurement.voltage}V, " - f"battery {measurement.battery}%, RSSI {rssi}dBm" - ) - - # Call measurement callback - if self.on_measurement: - self.on_measurement(measurement) - - except Exception as e: - logger.error(f"Error parsing ATC packet from {mac}: {e}") - - def _handle_unknown_sensor(self, mac: str, rssi: int, temperature: float, - humidity: int, battery_percent: int, battery_voltage: int): - """ - Handle discovery of unknown sensor. - - Args: - mac: MAC address with colons - rssi: Signal strength - temperature: Temperature reading - humidity: Humidity reading - battery_percent: Battery percentage - battery_voltage: Battery voltage in mV - """ - # Get or construct device name from MAC - # ATC sensors advertise as ATC_XXXXXX where XXXXXX is last 3 bytes - mac_suffix = mac.replace(":", "")[-6:] - device_name = f"ATC_{mac_suffix}" - - # Check if already discovered - if self.discovery_manager.is_known(mac): - # Just update the discovery record - self.discovery_manager.add_or_update( - mac, device_name, rssi, temperature, humidity, - battery_percent, battery_voltage - ) - return - - # New sensor - discover and notify - is_new = self.discovery_manager.add_or_update( - mac, device_name, rssi, temperature, humidity, - battery_percent, battery_voltage - ) - - 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) - - def _parse_atc_data(self, data_str: str) -> Optional[tuple]: - """ - Parse ATC advertisement data. - - Returns: - Tuple of (temperature, humidity, battery_percent, battery_voltage, adv_number) or None - """ - try: - # Temperature: bytes 22-26, signed int16, big endian, /10 - temp_hex = data_str[22:26] - temp_raw = int(temp_hex, 16) - if temp_raw & 0x8000: # Check sign bit - temp_raw = temp_raw - 0x10000 - temperature = temp_raw / 10.0 - - # Humidity: bytes 26-28, uint8 - humidity = int(data_str[26:28], 16) - - # Battery: bytes 28-30, uint8 - battery_percent = int(data_str[28:30], 16) - - # Battery voltage: bytes 30-34, uint16, big endian, mV - battery_voltage = int(data_str[30:34], 16) - - # Advertisement number: last 2 bytes - adv_number = data_str[-2:] - - return (temperature, humidity, battery_percent, battery_voltage, adv_number) - - except (ValueError, IndexError) as e: - logger.debug(f"Error parsing ATC data: {e}") - return None diff --git a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/utils.py b/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/utils.py deleted file mode 100644 index 529599a..0000000 --- a/debian/sensorpajen/opt/sensorpajen/src/sensorpajen/utils.py +++ /dev/null @@ -1,421 +0,0 @@ -# -*- coding: utf-8 -*- -# This file is from https://github.com/colin-guyon/py-bluetooth-utils -# published under MIT License - -# MIT License - -# Copyright (c) 2020 Colin GUYON - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -""" -Module containing some bluetooth utility functions (linux only). - -It either uses HCI commands using PyBluez, or does ioctl calls like it's -done in Bluez tools such as hciconfig. - -Main functions: - - toggle_device : enable or disable a bluetooth device - - set_scan : set scan type on a device ("noscan", "iscan", "pscan", "piscan") - - enable/disable_le_scan : enable BLE scanning - - parse_le_advertising_events : parse and read BLE advertisements packets - - start/stop_le_advertising : advertise custom data using BLE - -Bluez : http://www.bluez.org/ -PyBluez : http://karulis.github.io/pybluez/ - -The module was in particular inspired from 'iBeacon-Scanner-' -https://github.com/switchdoclabs/iBeacon-Scanner-/blob/master/blescan.py -and sometimes directly from the Bluez sources. -""" - -from __future__ import absolute_import -import sys -import struct -import fcntl -import array -import socket -from errno import EALREADY - -# import PyBluez -import bluetooth._bluetooth as bluez - -__all__ = ('toggle_device', 'set_scan', - 'enable_le_scan', 'disable_le_scan', 'parse_le_advertising_events', - 'start_le_advertising', 'stop_le_advertising', - 'raw_packet_to_str') - -LE_META_EVENT = 0x3E -LE_PUBLIC_ADDRESS = 0x00 -LE_RANDOM_ADDRESS = 0x01 - -OGF_LE_CTL = 0x08 -OCF_LE_SET_SCAN_PARAMETERS = 0x000B -OCF_LE_SET_SCAN_ENABLE = 0x000C -OCF_LE_CREATE_CONN = 0x000D -OCF_LE_SET_ADVERTISING_PARAMETERS = 0x0006 -OCF_LE_SET_ADVERTISE_ENABLE = 0x000A -OCF_LE_SET_ADVERTISING_DATA = 0x0008 - -SCAN_TYPE_PASSIVE = 0x00 -SCAN_FILTER_DUPLICATES = 0x01 -SCAN_DISABLE = 0x00 -SCAN_ENABLE = 0x01 - -# sub-events of LE_META_EVENT -EVT_LE_CONN_COMPLETE = 0x01 -EVT_LE_ADVERTISING_REPORT = 0x02 -EVT_LE_CONN_UPDATE_COMPLETE = 0x03 -EVT_LE_READ_REMOTE_USED_FEATURES_COMPLETE = 0x04 - -# Advertisement event types -ADV_IND = 0x00 -ADV_DIRECT_IND = 0x01 -ADV_SCAN_IND = 0x02 -ADV_NONCONN_IND = 0x03 -ADV_SCAN_RSP = 0x04 - -# Allow Scan Request from Any, Connect Request from Any -FILTER_POLICY_NO_WHITELIST = 0x00 -# Allow Scan Request from White List Only, Connect Request from Any -FILTER_POLICY_SCAN_WHITELIST = 0x01 -# Allow Scan Request from Any, Connect Request from White List Only -FILTER_POLICY_CONN_WHITELIST = 0x02 -# Allow Scan Request from White List Only, Connect Request from White List Only -FILTER_POLICY_SCAN_AND_CONN_WHITELIST = 0x03 - - -def toggle_device(dev_id, enable): - """ - Power ON or OFF a bluetooth device. - - :param dev_id: Device id. - :type dev_id: ``int`` - :param enable: Whether to enable of disable the device. - :type enable: ``bool`` - """ - hci_sock = socket.socket(socket.AF_BLUETOOTH, - socket.SOCK_RAW, - socket.BTPROTO_HCI) - print("Power %s bluetooth device %d" % ('ON' if enable else 'OFF', dev_id)) - # di = struct.pack("HbBIBBIIIHHHH10I", dev_id, *((0,) * 22)) - # fcntl.ioctl(hci_sock.fileno(), bluez.HCIGETDEVINFO, di) - req_str = struct.pack("H", dev_id) - request = array.array("b", req_str) - try: - fcntl.ioctl(hci_sock.fileno(), - bluez.HCIDEVUP if enable else bluez.HCIDEVDOWN, - request[0]) - except IOError as e: - if e.errno == EALREADY: - print("Bluetooth device %d is already %s" % ( - dev_id, 'enabled' if enable else 'disabled')) - else: - raise - finally: - hci_sock.close() - - -# Types of bluetooth scan -SCAN_DISABLED = 0x00 -SCAN_INQUIRY = 0x01 -SCAN_PAGE = 0x02 - - -def set_scan(dev_id, scan_type): - """ - Set scan type on a given bluetooth device. - - :param dev_id: Device id. - :type dev_id: ``int`` - :param scan_type: One of - ``'noscan'`` - ``'iscan'`` - ``'pscan'`` - ``'piscan'`` - :type scan_type: ``str`` - """ - hci_sock = socket.socket(socket.AF_BLUETOOTH, - socket.SOCK_RAW, - socket.BTPROTO_HCI) - if scan_type == "noscan": - dev_opt = SCAN_DISABLED - elif scan_type == "iscan": - dev_opt = SCAN_INQUIRY - elif scan_type == "pscan": - dev_opt = SCAN_PAGE - elif scan_type == "piscan": - dev_opt = SCAN_PAGE | SCAN_INQUIRY - else: - raise ValueError("Unknown scan type %r" % scan_type) - - req_str = struct.pack("HI", dev_id, dev_opt) - print("Set scan type %r to bluetooth device %d" % (scan_type, dev_id)) - try: - fcntl.ioctl(hci_sock.fileno(), bluez.HCISETSCAN, req_str) - finally: - hci_sock.close() - - -def raw_packet_to_str(pkt): - """ - Returns the string representation of a raw HCI packet. - """ - if sys.version_info > (3, 0): - return ''.join('%02x' % struct.unpack("B", bytes([x]))[0] for x in pkt) - else: - return ''.join('%02x' % struct.unpack("B", x)[0] for x in pkt) - - -def enable_le_scan(sock, interval=0x0800, window=0x0800, - filter_policy=FILTER_POLICY_NO_WHITELIST, - filter_duplicates=True): - """ - Enable LE passive scan (with filtering of duplicate packets enabled). - - :param sock: A bluetooth HCI socket (retrieved using the - ``hci_open_dev`` PyBluez function). - :param interval: Scan interval. - :param window: Scan window (must be less or equal than given interval). - :param filter_policy: One of - ``FILTER_POLICY_NO_WHITELIST`` (default value) - ``FILTER_POLICY_SCAN_WHITELIST`` - ``FILTER_POLICY_CONN_WHITELIST`` - ``FILTER_POLICY_SCAN_AND_CONN_WHITELIST`` - - .. note:: Scan interval and window are to multiply by 0.625 ms to - get the real time duration. - """ - print("Enable LE scan") - own_bdaddr_type = LE_PUBLIC_ADDRESS # does not work with LE_RANDOM_ADDRESS - cmd_pkt = struct.pack(" 31: - raise ValueError("data is too long (%d but max is 31 bytes)", - data_length) - cmd_pkt = struct.pack("dI&e^)6j7!uPtznV^bLhdQ|g7C?Lks~S56xK}Ylte%|z6l;vu7U6nSaxz^eQ*MP3~Qu;hWYiB zNM%6XM{~h=lLVTrQJ$BuBDloKojA1$~;%I~hGy@}DeXP0e)q4H8c7wQ-FO_22L?8JQ<-P^`5oWwMv$joRbL@Dfx8RPk&-y6>6{cgMLY`ZlZjr-^G z@uYXr|Jbe?Zk#(xjF}{A0oNFCE;L)Rz}l|7;q2nmyf^IpJm_`XOY}B%OIMRYd!==v zTFs_mYoM)m!yZ=AAzS{XH*DKjrX0QJ-C&#k&>g*>&-$0W(bcSd3YDh@cqFKRaBR>n zOErtBNVRu98ea6@&nLaXsMDSIhqK<~x-)3MJ3P!;JE;~~F%{TQKRN|$3j5vw4;}Qb zdxLg=crnU@Zu(6|c)O!tWihqg@UsW7mu>E~;5W}4`q^`b@5Lm*#l6nk*7;HE~0(G{``c1eGNDE0f2xaOCfuO4@ttdK=n8{JTENZ> zC_JzgWSv2_$#!5&&(ig&iO+gtp4R1B$xQWZI=a@1YLYu@a-!URr-rqOmM5?2={rb7 z^3N2zv@1}j88YS22W|fk=#z_ia>&%7hJiMlL9c<8S0KsL26G{zNNirgiMexF>DdE< z5MKGDC$_c4Q&k>ioCsbi6%QjpJUyc*~^m)!75;;uqoW-MxftC}LeU0N@Sa?e}ERT2`5WVz+f z?xp4~o1}Q{{I(0`gyt)`dWh`#y=ckI1#w02CKCQ%cb{)4ZMfp>`K^9xu+3n{7G2zJ z?q8tb3jj4ijn{=>>lRPOFPXf%h;FxHpqp1wwChu3$(aq*&3m<`KNY zjeI4tB94&~SngKpbf&UwBW}XJ=k4!bl{=luT`a;Pjyx zItA(Je7^!IhIgX&-;Owsb5OQ43FEwT8uO$v5tOLLs+El8Lao%6HJJ7(%fDS}r>#;^ zvIzZd;{TNAQzNbA5<5*Hu1&R!vntg%_Z?|f2y(JZCakT4dtQ?Z1JU_klN&*p!ObVY z*4<1+@NBL2%KNPIME8HvN!(XoC7LU<8XIJ*x@2D>G*#V8kVy1ZyslQkTV02scz7dX z9>TL{7GXLXr#b;i1)0@#a(3e+N8MV4`QB4fiX?Tcrka#B*0Xd4Y8J|PE5}c6;Nrnt zf!NV9x^*zPvOG(o*7L0B9KDqBLV*Cbj@0k>w-GS{zn6g@LP}fI*p<^wnk7GhK=kuu z6}8s5;g@2iav8{63K<7*_!O))n6a?7RHpt^fNUY}bU*)^HW|Ca+j9T{=Y8&1ffO)^LE;y<8=J{$`pKvB>&bZQ^pgeN?xF|-MUcVWm=Q?# zR4|{2IFN(Rl!(myR!J~kz_}tW|)M@ah(Ft1wivLpVMF1h#MZ1Kk*55%go^ z;OaJTjJ{PZHlBB<(P~&j2;!J884JMG_#bJfA-ousjE^u4(s>1~P(j zuY!l(sB9H#jaN1lhi?xL#V#N(#h`TXm;qP;_>FqB`V3kqXJ8rk z_r>lylw}Oy4EhLqzc)kzP^jP^Jk0cm(RlLy==_hW%vh%ktgnBZUmRXEGSU_sNy=hx%W?euhX{qf?{^y9_l z`TGtbvs8I+@MNEW8yAZh6cq38Bp?>`fkz=dbkDG5t?nx?cd>A?ihge3oFJ#9YV@}_ z8iMmssWbz=5vP#?h?|OE|MqXT9TT{G$eZF(w1)P$ap8?-jWU6WSS-vCdFiyi3ayvK5is zz&;*{FNB&n`QrM*sv|Q7`RQuoZkK*xOyY=-2|?;09&Hs!xJc$sEU}hR;Q)KHa~s!nhDG3c~^Y0OqVmcfza^v3hhMV#%TUpayCn z?$uBMkG>@;y8;w5;0j+yb+UoC4Pt9sn;WRXM%{e_2iwfSo>}t7aHtJk(7s~4`3Sdy zrUv{%$j+dYekRDMgXX=(t;wtbNJU!sjDh_f)9L{k^fo&zZOu7Qg zn335#o&O*xA&|MD2&|OF5S#2H&}JzQf4^pu30tb$uo1y;L}5=7GCz1} z5dFY zL_%P6!;Xf;iWCz?SaoQ+t|5;}>6m=Ou`sr{2&`4%gTQGxVYC4srty6vxm-745N;Sx z(a3iA2BT!gRx>^zB+IQVeEpVR^b#q_4MeKefigemn?ex{qXSh@bK)( zMUSgR`Hw$(_HV?hb-Yza4a}A;@r}g__hu!4Z%eOA>zy`(nCGH_o6#-2diU=2|89KX zxjDJIz&{y<1WuXpI^Sexc)dQL)aU*z|REGRqAI%Vebn^GpR<_%P_V_um?hLeP6=Qh@@nY9q!fEi`hOlS%9> zp5R?KbN2gzn&BTN`-?vTC<8tpI8SS(km@lfUaQ~xeN5-NzjZOwIZ&$|FZO0AK8G3c z)Ww(I)U&D2IW*8$0g-nIkdd5`%^X!LHoRAtOLB6xVtw5IS`Ujr624-ksoQO>%^92> zo3fq~O|M071rR)5i~icZ;^V%_`fkZ%&}d@!bee8_=`YoEI^au#-!#Omw@r#bV=p+5 zSPNyFbaLIzY&Z+!0R~oUtX<$C!qg@sMXy(+fH(q$Pn$0}{ataiH5qn5O|ul)m=Hub zP-_(>Daio_j-6`I!EjM%vx1`p&q>Zn6eW)$PU0Rq4p;)91{pF0Fja}{1>$2;EGqZ0 zS|3|WVMGxaDFJ_0g8!{sV|LPm;{O@N9ie7LH%#Oda!Bz-?yhJC+Z$tuI6j};-AqT< z)0+!Sc$;V@qo4&N1OUwsqBrNOG%Fb5YoM})CL7@R?Xb+9k4@&CH1#Fu*r+rDVQXkr znJ*=Fn`C}FQBl4k=nip4s4^!+F#D8hpn>X@XJrnAcllqKeUM zO~$BJjHF?3&=V`%bEfck=mB|E_dw+c6wXS-nU({A&_r)mD0O;h38#|d-|xEOXl(c1 z{gas&Qyl8325lKxRenejP_!F0Kb~hL#E~SF&OA#B^!rQDMdSxn{165BQNbeMs1Hs+ z0hovI!*cLBHTsTC2M+y4e6F%A&#BO7+-O6oR;v)WrG}dT`8ItUxGE4`mFs|84WNx=% zK1(Y@B~eaQ6?xJtb0f{me>bEuzfJo7Pwi*3F3$#W^R#iR3xeq)Q)M?oN8a8hAW&|I z-@QlBg8DAt9tTO9yPD}WbBJK-2B7|pF=r~EJX7GJDl27Z8a+3OM!3$me1a9z1eHf) zwdDz?DeVH1rP^dntMUfrjj_@&NQ=zyQp>rbGQ$}dv>xIlo38hM9315Nd$i6+$$mP%kshUE5*aim^<&?y42iWpI_QJ@6F^=SmN)$K(9?d}ISGRM-)44X8V-LR zcpBug{?wz`N2aA7h%MV&!B9h<5=#I{E%9h3I-VVBGF`9%!8Eft-zcC#zLA;+&;V0{ z!4bWpCKA25=k|+OyVrx8vJ2qMdE4aTdn0%%Xt_6+$28bG?piNe@ zaOiAktDJtQ8-AE#oQ9>Y!l-6Mq`9EkNJSVxq&57~<$=Upt^jk$%KK;YzGj!08g46XzIR!W~=h#%7k|P15vo@vW&6R378rQ1MC!N1w6a$0-G+&Tg_K zV6@ny1dYi?u4MLLDz{>%w3lVEj34g^%n(OOtgz&2I=%1}w)6S){2wr^`^QFY0H~Ds-6gO;^Iit^NG` z(jti|*tGgC$rrqMm8w$n;wkp{Z~C^IuCr3fG}bEIrsJQC02P+(Hy8kW-Ic$slJ?^g z+H7N-9*j0}0L{HY(RSf}xFQ++S;0+j%<2c-uEQp>0z^+nRG- zx69+&-q)4}fKAi}+HL=bT!5`Y@U+agi;zEl61L&SZLR@>1*@&h+i{u6ihq;yarwn1 zgrZSm43YDpTg|XhNO@^TiQUUE23Pn2%o~^()4RrvU7ScR)7CXX>QP&E1&FzUdCPs@ z({Q)0&7+!g92w^+&Cge!n9y#T#_r}lL#W%!@$4dHJNe<;H*X9pOju!$ynBLeJ~0>l zxlQ_3aRr;a!^OPJ6iVGlQ*$BOb!B_Q7C(u|7seROE0e4#GgHnAFXQWTu(o6=xTW&2 z(tcVAvQl@?yY@p+^0(4o?g<@29F1qaZd4x_(6y4w*w@RY3rA0CuRn16C8$?{aLE*2 zSwP1j8t=@}HZDz7iZNz91O_q0rY(tIy1L)Ah83a`kKnsYkYjI(bWhjwW(ZS7v zB4mz*QK7is#$>KLin6e<%}194aGRKqU2h@@I1IFwtuP!P0e?_nCtoEH{5=Y$;N6Ns zt8U%)DwH~V+H0B0QFENF$=@o>C$?I+>6Yt3PDg8t8L-b#$=2ZQlD_>iw>2}v?@DHX zu^2bp#imDR$iIJ^DHQal@u2&OuvgR z)@Rp%$vts{)@ojHcMZqvBBeG5;_VhfH<2>(nz%{;v6C00r z8rTRHV~=JcD0woGD&#vjQsF;xh@>l9f>C?mL^fq5Y@7~=rC{GgL0R)H`up+?i@xjc V&~*>BanqIB{R2fRrF&!n000~6dv^c; diff --git a/debian/sensorpajen/usr/share/doc/sensorpajen/examples/discovered_sensors.json.example b/debian/sensorpajen/usr/share/doc/sensorpajen/examples/discovered_sensors.json.example deleted file mode 100644 index f57cde5..0000000 --- a/debian/sensorpajen/usr/share/doc/sensorpajen/examples/discovered_sensors.json.example +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "mac": "A4:C1:38:12:34:56", - "name": "ATC_123456", - "rssi": -65, - "first_seen": "2025-12-27T10:30:15", - "last_seen": "2025-12-27T10: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-27T11:00:00", - "last_seen": "2025-12-27T11:10:00", - "sample_reading": { - "temperature": 19.8, - "humidity": 52, - "battery_percent": 65, - "battery_voltage": 2800 - }, - "status": "ignored", - "ignored_at": "2025-12-27T11:15:00", - "ignore_reason": "Test sensor, not needed" - } -] diff --git a/debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensorpajen.env.example b/debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensorpajen.env.example deleted file mode 100644 index 88b6490..0000000 --- a/debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensorpajen.env.example +++ /dev/null @@ -1,29 +0,0 @@ -# MQTT Configuration -MQTT_HOST=192.168.0.114 -MQTT_PORT=1883 -MQTT_USER=hasse -MQTT_PASSWORD=casablanca -MQTT_CLIENT_ID=mibridge - -# 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 - -# ntfy Notifications (optional) -NTFY_ENABLED=false -NTFY_URL=https://ntfy.sh -NTFY_TOPIC=sensorpajen -NTFY_TOKEN= diff --git a/debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensors.json.example b/debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensors.json.example deleted file mode 100644 index 7a033cf..0000000 --- a/debian/sensorpajen/usr/share/doc/sensorpajen/examples/sensors.json.example +++ /dev/null @@ -1,37 +0,0 @@ -{ - "sensors": [ - { - "mac": "A4:C1:38:98:7B:B6", - "name": "mi_temp_1", - "comment": "Example sensor - replace with your sensors" - }, - { - "mac": "A4:C1:38:29:03:0D", - "name": "mi_temp_2" - }, - { - "mac": "A4:C1:38:62:CA:83", - "name": "mi_temp_3" - }, - { - "mac": "A4:C1:38:D5:EA:63", - "name": "mi_temp_4" - }, - { - "mac": "A4:C1:38:7C:9C:63", - "name": "mi_temp_5" - }, - { - "mac": "A4:C1:38:68:2C:DA", - "name": "mi_temp_6" - }, - { - "mac": "A4:C1:38:AD:74:2B", - "name": "mi_temp_7" - }, - { - "mac": "A4:C1:38:46:9F:D1", - "name": "mi_temp_8" - } - ] -} diff --git a/debian/sensorpajen/usr/share/doc/sensorpajen/readme.md.gz b/debian/sensorpajen/usr/share/doc/sensorpajen/readme.md.gz deleted file mode 100644 index 617a1b154ffa1caa5bd7cd6ccea706c6a5f7d257..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2143 zcmV-l2%z^LiwFP!000021Faa_ZsW%FonLV(1z20Lq}AG4IKn!BR+dv2mZeDA?506r zkI0cVHp$`645bJ`ilXRKQJ@dqmlg$zJoOj)J^KUgC-j^dQlcoi=`Jc524{Hg_rofT zd90;M*gcOuZ^U#m@w4Gv_Lj5jPy)E%0K)`k*uE!c3@W$rL}dwEp}P4A+bcq8-6JjYJ^K z>c~nrWd;eZwq&ye-u)grsgV&g!Uq)5Fl9i-Fk>P_I*!R7V7$^uJy=_9DNvq!nW~tD z0fh4IPQ-VP;6G5cfU6jWD>Fw*3G*M=U52s{%A_m=9^;dYBOV*XdjHqIkV(2So{75@ zH4unLx);D;!IWUrkUMk#YCO2MwjJ@esZg9itbMnfd|*%`9*{y?_rK;!!fQ(?=1Z;) z%ZP(99Gvs1U~zrQG!FHedsXO$GUn*`L~07hm3Jo$`1bMz-PThTegVH}>Ovypya__vD#qtqy09EK=61u?9+!)Lfy$8dQ z0{Ueb?jHJ`F2+}YiK(f4RI#9$Lg@J{VN($bLnhH_Ac+ZH!V~;=6LWK;*zzcUWEZSXf=e`#`1VTYQ!60blS~!qqiZD$wVt!FHNM1W0 zLvttJphN3)gT;ztVXiA4N)|Zvu+oT_KzK~GppF{cA>=2o#=Vg>kF8(1q&pNH78GN5 z2S#Tkw!?eQF?;ZpN%dY7yx+pfYGVPYo)!Vn?39Hz<+tjd%T^mO<2+zI8xptsgY34w z$!9{l`Q>0dIct6XtkHUY+GsUePu=x!Fv9E8(_?oei(Pm6y)zOnW)aVzI^*%{!RY)f zVOlSx3fy(~>auq|xw$+)D=KMUO$Niu?#*!2ySRLF))$ja6MB|wSk3zIeob8PXKL7# z`ql&Z!J}-bM9i=mbYwwJ+uP=;~q1@C)CXdJSZmjMBu>`iFxo6X$83=9Df#CpPDSceRAsLgS zV2bPiDSdt(CWhA2r)x)EglJd_l56BLDU4a3SF2q{iByKgrdDe^NE{l%6;LBAjAVer zA`Va@;=k~`Rs(9a3xO@KP^Jn0l}4i$%Jj>t9NQt{QU_g*1h& z&}Wcfi{Xw)H>9bgf)z04zNy&xLGuuw9cazr{b$$}hITCgi_rY3o3o-{JKJ zGh!lcBrDkZRiTq47!YoFMwj3ckVt`*zKrf{Tq02F;DzeG%l@q~H~FflGCEni$B?9G zSSoUa+Cg>t-q75(#zfRjmB~g%2e3wem!YRdgu1MG5uE_sAX!$o2|K^C$}?GHV5MJc7H=Ybswnyn^wF33U z+TG`Tfm>1HrqS{?@0zGdlaE^FKXgOiw~3F^7{@})34{IafC|1$O`gYx84Kc2%uFiH zIWk!h6KhFboGmaeX~MLK_+o8^zPQWl4|U-yo32?+$TDCwz#QrfZ3b+fUBpw#!VBY-@A`NnIhD52_kZzyGm=aYin2d6l?B;3EctawnA&P8vX^i zxCHc-B(t!sU=F#fIBrY!tJa<}4Nevm7}vB1Y{giTF@NC8SKkb-;h=vx$+^?VtF1{8 VVY7j){Xqlae*h8-KU+{0001MGB?$lk diff --git a/pyproject.toml b/pyproject.toml index 75bf41c..4bfef94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta" [project] name = "sensorpajen" -version = "2.0.0" +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 = [ diff --git a/readme.md b/readme.md index 1838229..71f2a50 100644 --- a/readme.md +++ b/readme.md @@ -28,10 +28,10 @@ The easiest way to install on Raspberry Pi OS is using the pre-built Debian pack ```bash # Download the latest release -wget https://gitea.wahlberg.se/api/v1/repos/fredrik/sensorpajen/releases/download/v2.0.0/sensorpajen_2.0.0_all.deb +wget https://gitea.wahlberg.se/api/v1/repos/fredrik/sensorpajen/releases/download/v3.0.0/sensorpajen_3.0.0_all.deb # Install -sudo dpkg -i sensorpajen_2.0.0_all.deb +sudo dpkg -i sensorpajen_3.0.0_all.deb # Configure sudo nano /etc/sensorpajen/sensorpajen.env # Edit MQTT settings @@ -82,7 +82,8 @@ The TUI allows you to: **Keybindings:** - `a`: Approve selected sensor - `i`: Ignore selected sensor -- `e`: Edit sensor name +- `e`: Edit sensor name and comment +- `v`: View details (MAC/name/comment) - `u`: Unignore sensor - `Delete`: Remove sensor from monitoring - `r`: Refresh data @@ -90,17 +91,17 @@ The TUI allows you to: When you approve a sensor, it's added to your configuration and the service automatically starts monitoring it. -### Legacy CLI Approval +### Legacy CLI Approval (Deprecated) -If you prefer the command line, you can still use: +The recommended workflow is the TUI (`sensorpajen-tui`). A legacy CLI tool still exists: ```bash -sudo sensorpajen approve-sensors +sudo sensorpajen-approve-sensors ``` ### MQTT Settings -Edit `config/sensorpajen.env`: +Edit `/etc/sensorpajen/sensorpajen.env`: ```bash MQTT_HOST=192.168.1.10 @@ -128,6 +129,7 @@ Sensors are automatically managed via the approval workflow. You can also manual } ] } +``` ## Service Management @@ -227,11 +229,11 @@ mosquitto_sub -h -u -P -t "MiTemperature2/#" -v **Sensor not found:** ```bash -# Run sensor discovery -sudo sensorpajen approve-sensors +# Run the TUI to view/approve newly discovered sensors +sudo sensorpajen-tui -# Check discovered sensors -sudo cat /var/lib/sensorpajen/discovered_sensors.json | jq '.' +# Check recent logs +sudo journalctl -u sensorpajen -n 100 ``` ### Development Installation diff --git a/scripts/dev-remote.sh b/scripts/dev-remote.sh index faf230b..10414d7 100755 --- a/scripts/dev-remote.sh +++ b/scripts/dev-remote.sh @@ -49,6 +49,7 @@ 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/" @@ -127,11 +128,17 @@ ssh -t $REMOTE_USER@$REMOTE_HOST " 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 config/sensorpajen.env...' + 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) @@ -191,6 +198,7 @@ ssh -t $REMOTE_USER@$REMOTE_HOST " source config/sensorpajen.env set +a fi + export TUI_LOG_FILE=dev_backend.log # Run TUI python3 -m sensorpajen.tui.app " diff --git a/scripts/verify-deb.sh b/scripts/verify-deb.sh index e6c72ab..dde2778 100755 --- a/scripts/verify-deb.sh +++ b/scripts/verify-deb.sh @@ -136,7 +136,7 @@ echo "" # Show package contents echo "Package contents:" echo "======================================================================" -dpkg-deb -c "$DEB_FILE" | head -20 +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" diff --git a/src/sensorpajen/__init__.py b/src/sensorpajen/__init__.py index d931f51..9560109 100644 --- a/src/sensorpajen/__init__.py +++ b/src/sensorpajen/__init__.py @@ -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" diff --git a/src/sensorpajen/config.py b/src/sensorpajen/config.py index a1a80a0..ed60912 100644 --- a/src/sensorpajen/config.py +++ b/src/sensorpajen/config.py @@ -100,6 +100,7 @@ class SensorConfig: """ self.config_file = Path(config_file) self.sensors: Dict[str, str] = {} + self.comments: Dict[str, str] = {} self.load() def load(self): @@ -119,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}") @@ -147,6 +151,10 @@ 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. @@ -157,8 +165,25 @@ class SensorConfig: comment: Optional comment """ mac = mac.upper() + logger.debug(f"add_sensor called: MAC={mac}, name={name}") self.sensors[mac] = name - self.save(mac, name, comment) + 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): """ @@ -170,6 +195,7 @@ class SensorConfig: 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: @@ -196,27 +222,37 @@ class SensorConfig: 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: - s['comment'] = comment + 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: + if comment is not None and comment != "": new_sensor["comment"] = comment sensors.append(new_sensor) @@ -225,12 +261,22 @@ class SensorConfig: # 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}") + logger.error(f"Error saving sensor config: {e}", exc_info=True) raise e diff --git a/src/sensorpajen/discovery_manager.py b/src/sensorpajen/discovery_manager.py index 03312fc..992a079 100644 --- a/src/sensorpajen/discovery_manager.py +++ b/src/sensorpajen/discovery_manager.py @@ -178,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}" diff --git a/src/sensorpajen/main.py b/src/sensorpajen/main.py index 925b77f..8ea0e05 100644 --- a/src/sensorpajen/main.py +++ b/src/sensorpajen/main.py @@ -127,7 +127,7 @@ class Sensorpajen: if len(self.sensor_config.sensors) == 0: self.logger.warning("No sensors configured") self.logger.warning("Starting in discovery-only mode") - self.logger.warning("Use 'sensorpajen approve-sensors' to add sensors") + self.logger.warning("Use 'sensorpajen-tui' to add sensors") # Initialize discovery manager self.logger.info("Initializing discovery manager...") diff --git a/src/sensorpajen/tui/app.py b/src/sensorpajen/tui/app.py index 9c0be7f..6262842 100644 --- a/src/sensorpajen/tui/app.py +++ b/src/sensorpajen/tui/app.py @@ -1,11 +1,62 @@ 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 +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.""" @@ -65,15 +116,17 @@ class SensorpajenApp(App): } """ + # Use priority bindings so keys still reach the App even when a DataTable has focus. 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"), + 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): @@ -81,6 +134,14 @@ class SensorpajenApp(App): 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.""" @@ -136,6 +197,50 @@ class SensorpajenApp(App): """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() @@ -152,16 +257,58 @@ class SensorpajenApp(App): 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: + + # 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: - self.sensor_config.add_sensor(mac, name) - self.discovery_manager.approve(mac) - self.notify(f"Approved {mac} as {name}") - self.refresh_data() - except Exception as e: - self.notify(f"Error approving sensor: {e}", severity="error") + 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.""" @@ -175,8 +322,10 @@ class SensorpajenApp(App): 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) + 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}") @@ -184,6 +333,8 @@ class SensorpajenApp(App): 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 @@ -194,17 +345,90 @@ class SensorpajenApp(App): 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: + 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: - self.sensor_config.add_sensor(mac, name) - self.notify(f"Updated {mac} to {name}") - self.refresh_data() - except Exception as e: - self.notify(f"Error updating sensor: {e}", severity="error") + 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) @@ -215,18 +439,63 @@ class SensorpajenApp(App): key = row[0] current_value = row[1] - new_value = await self.push_screen(InputModal(f"Edit {key}", initial_value=str(current_value))) - if new_value is not None: + def _on_value(new_value: Optional[str]) -> None: + if new_value is None: + return try: save_env_var(key, new_value) - self.notify(f"Updated {key}. Restart required!", severity="warning") - # Temporarily update the view although it won't take effect until restart + # Update current runtime env for display (won't take effect in backend until restart) import os - os.environ[key] = new_value # Update current runtime env for display + 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": @@ -238,17 +507,32 @@ class SensorpajenApp(App): row = table.get_row_at(table.cursor_row) mac = row[0] - - try: - self.sensor_config.remove_sensor(mac) - - # Also need to reset its status in DiscoveryManager to make it show up in Discovery again - self.discovery_manager.unignore(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") + + 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.""" @@ -268,11 +552,16 @@ class SensorpajenApp(App): def refresh_data(self) -> None: """Load data from managers and update tables.""" - self._update_discovery_table() - self._update_configured_table() - self._update_ignored_table() - self._update_settings_table() - self._update_dashboard() + 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) diff --git a/src/sensorpajen/tui/modals.py b/src/sensorpajen/tui/modals.py index 4acaf41..cc37665 100644 --- a/src/sensorpajen/tui/modals.py +++ b/src/sensorpajen/tui/modals.py @@ -1,7 +1,9 @@ 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 +from textual.containers import Vertical, Horizontal, VerticalScroll +from textual.widgets import Static class InputModal(ModalScreen[str]): """A modal screen for text input.""" @@ -31,3 +33,110 @@ class InputModal(ModalScreen[str]): 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() diff --git a/tests/test_config.py b/tests/test_config.py index 7818610..4f409db 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,6 +34,35 @@ def test_sensor_config_load(tmp_path): 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" @@ -47,10 +76,12 @@ def test_sensor_config_add_remove(tmp_path): # 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") diff --git a/tests/test_discovery_manager.py b/tests/test_discovery_manager.py index 3cabfac..9ead23d 100644 --- a/tests/test_discovery_manager.py +++ b/tests/test_discovery_manager.py @@ -3,6 +3,12 @@ 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)) @@ -55,3 +61,94 @@ def test_discovery_manager_persistence(tmp_path): 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 diff --git a/tests/test_tui.py b/tests/test_tui.py index a9f741a..d2e6484 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,8 +1,225 @@ import pytest -from sensorpajen.tui.app import SensorpajenApp +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_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 +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" From abdea54788093c214931ae8b6f27056418fa8b39 Mon Sep 17 00:00:00 2001 From: Fredrik Wahlberg Date: Mon, 29 Dec 2025 15:35:53 +0100 Subject: [PATCH 4/4] Release v3.0.1 Fix Debian runtime deps for TUI (Textual) --- VERSION | 2 +- debian/changelog | 6 ++++++ pyproject.toml | 2 +- readme.md | 4 ++-- requirements.txt | 1 + src/sensorpajen/__init__.py | 2 +- 6 files changed, 12 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 4a36342..cb2b00e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 +3.0.1 diff --git a/debian/changelog b/debian/changelog index f34e75f..0e373a5 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +sensorpajen (3.0.1) stable; urgency=medium + + * Fix Debian runtime dependencies for the TUI (ensure Textual is installed) + + -- Fredrik Mon, 29 Dec 2025 12:30:00 +0100 + sensorpajen (3.0.0) stable; urgency=medium * Production release v3.0.0 diff --git a/pyproject.toml b/pyproject.toml index 4bfef94..ad69ca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sensorpajen" -version = "3.0.0" +version = "3.0.1" description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC" readme = "readme.md" requires-python = ">=3.9" diff --git a/readme.md b/readme.md index 71f2a50..f088d88 100644 --- a/readme.md +++ b/readme.md @@ -28,10 +28,10 @@ The easiest way to install on Raspberry Pi OS is using the pre-built Debian pack ```bash # 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 +wget https://gitea.wahlberg.se/api/v1/repos/fredrik/sensorpajen/releases/download/v3.0.1/sensorpajen_3.0.1_all.deb # Install -sudo dpkg -i sensorpajen_3.0.0_all.deb +sudo dpkg -i sensorpajen_3.0.1_all.deb # Configure sudo nano /etc/sensorpajen/sensorpajen.env # Edit MQTT settings diff --git a/requirements.txt b/requirements.txt index 696a541..847a514 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pybluez bluepy paho-mqtt +textual>=0.40.0 diff --git a/src/sensorpajen/__init__.py b/src/sensorpajen/__init__.py index 9560109..1c58b37 100644 --- a/src/sensorpajen/__init__.py +++ b/src/sensorpajen/__init__.py @@ -5,6 +5,6 @@ Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors and publishes data to MQTT broker. """ -__version__ = "3.0.0" +__version__ = "3.0.1" __author__ = "Fredrik" __license__ = "MIT"