feat: implement Textual TUI and SQLite database for sensor management

This commit is contained in:
2025-12-29 09:39:33 +01:00
parent 4213b6101a
commit cfa24d1fa5
22 changed files with 1734 additions and 723 deletions

5
.gitignore vendored
View File

@@ -1,3 +1,8 @@
.*
__pycache__
temp
*.db
*.egg-info/
.venv/
build/
dist/

View File

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

View File

@@ -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.

564
ROADMAP-v2.md Normal file
View File

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

View File

@@ -1,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 <repo> /home/fredrik/dev/sensorpajen
cd /home/fredrik/dev/sensorpajen
### 2. Create Virtual Environment
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
### 3. Configure
mkdir -p ~/.config/sensorpajen
cp systemd/sensorpajen.env.example ~/.config/sensorpajen/sensorpajen.env
# Edit configuration
nano ~/.config/sensorpajen/sensorpajen.env
chmod 600 ~/.config/sensorpajen/sensorpajen.env
### 4. Convert Sensor Configuration
# Create sensors.json from your sensor list
### 5. Install Service
cp systemd/sensorpajen.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable sensorpajen
systemctl --user start sensorpajen
### 6. Verify
systemctl --user status sensorpajen
journalctl --user -u sensorpajen -f
```
5. Add troubleshooting section:
- Bluetooth permission issues
- MQTT connection problems
- Service won't start
- Log locations
---
## Configuration File Locations (Linux Best Practices)
### User Service Configuration
- **Service files**: `~/.config/systemd/user/`
- **Application config**: `~/.config/sensorpajen/`
- **Environment file**: `~/.config/sensorpajen/sensorpajen.env` (0600)
- **Sensor mapping**: `~/.config/sensorpajen/sensors.json` (0644)
### System Service (Alternative - Not Recommended)
If running as system service (not user service):
- **Service file**: `/etc/systemd/system/sensorpajen.service`
- **Config directory**: `/etc/sensorpajen/`
- **Environment file**: `/etc/sensorpajen/sensorpajen.env` (0600)
**Recommendation**: Use user service (current approach) since:
- No sudo required for service management
- Easier permission management
- Better security isolation
- Simpler Bluetooth access
---
## Success Criteria
The migration is complete when:
- ✅ Service starts automatically on boot
- ✅ All 8 Bluetooth sensors are being read
- ✅ MQTT messages are published correctly
- ✅ Service recovers automatically from crashes
- ✅ No hardcoded credentials in code
- ✅ Logs are visible via journalctl
- ✅ DHT11 functionality completely removed
- ✅ Legacy scripts removed
- ✅ Documentation is complete and accurate
- ✅ Service runs as user (not root)
- ✅ Virtual environment is working
---
## Rollback Plan
If issues arise during migration:
1. Stop new service:
```bash
systemctl --user stop sensorpajen
systemctl --user disable sensorpajen
```
2. Restore legacy scripts from legacy/ folder:
```bash
cp legacy/* .
```
3. Restore cron jobs:
```bash
crontab -e
# Uncomment:
# @reboot /home/fredrik/dev/sensorpajen/sensorer.sh
```
4. Reboot or manually start tmux session
---
## Future Enhancements
After successful migration, consider:
- [ ] Add Prometheus metrics endpoint
- [ ] Add systemd watchdog support
- [ ] Implement graceful sensor failure handling
- [ ] Add MQTT TLS support
- [ ] Create web dashboard for sensor status
- [ ] Add sensor calibration configuration
- [ ] Implement sensor auto-discovery
- [ ] Add health check endpoint
---
## Notes
- Keep legacy scripts during migration for safety
- Test thoroughly before removing cron jobs
- Monitor for at least 1-2 weeks before final cleanup
- Document any issues encountered during migration
- Take notes of actual MAC addresses and sensor names during conversion
---
## References
- systemd user services: `man systemd.service`
- XDG Base Directory: `~/.config/` for user configuration
- Bluetooth capabilities: `man capabilities`
- journalctl: `man journalctl`
- Python logging: https://docs.python.org/3/library/logging.html
## Completed Phases
-**Phase 0: Preparation & Cleanup** (2025-12-27)
-**Phase 0.1: Testing Infrastructure** (2025-12-29)
-**Phase 1: Modern TUI Management & Data Persistence** (2025-12-29)

15
Tasks.md Normal file
View File

@@ -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).

View File

@@ -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"]

View File

@@ -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`:

View File

@@ -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}")

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

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

View File

@@ -4,15 +4,14 @@ Discovery manager for tracking and managing discovered sensors.
Maintains a database of discovered sensors with their metadata and status.
"""
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
from dataclasses import dataclass
from . import config
from .db import DatabaseManager
logger = logging.getLogger(__name__)
@@ -30,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

View File

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

View File

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

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

@@ -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()

View File

@@ -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)

15
tests/conftest.py Normal file
View File

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

67
tests/test_config.py Normal file
View File

@@ -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 == {}

82
tests/test_db.py Normal file
View File

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

View File

@@ -0,0 +1,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"

View File

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

View File

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

8
tests/test_tui.py Normal file
View File

@@ -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