2 Commits

25 changed files with 2221 additions and 733 deletions

5
.gitignore vendored
View File

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

View File

@@ -194,6 +194,18 @@ Any agent making changes must:
* Explicit over implicit * Explicit over implicit
* Fewer moving parts * Fewer moving parts
* Easy to debug on a headless device * 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 # 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 ## Task: Debian Package Creation
**Status**: DONE (2025-12-27) **Status**: DONE (2025-12-27)
@@ -760,3 +821,26 @@ If you want, I can also:
* Write a **follow-up roadmap entry** for sensor management * Write a **follow-up roadmap entry** for sensor management
Just tell me how you want to evolve it next. 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 ## Overview
This roadmap defines the evolution of Sensorpajen from a CLI-based tool to a full-featured TUI application for sensor management and monitoring.
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 ## 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.
### 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**: **Notes**:
- Created modern Python package structure with src/ layout - Migrated discovery data to SQLite for better metadata tracking.
- Converted INI sensor config to JSON format (sensors.json.example) - Implemented a full-screen TUI using Textual with Discovery, Configured, and Ignored views.
- Environment-based configuration instead of hardcoded values - Added support for interactive Approve, Ignore, Edit, and Remove actions.
- DHT11 sensor functionality removed as planned
- Legacy scripts preserved in legacy/ folder
#### Tasks: ### Tasks:
- ✅ Create new directory structure -**Database Migration**: Replace `discovered_sensors.json` with a SQLite database.
- ✅ Create pyproject.toml with dependencies -**Textual TUI Scaffolding**: Initialize a full-screen TUI using the `Textual` library.
- ✅ Remove DHT11 functionality -**Sensor Management View**: Interactive management of all sensor states.
- ✅ Move legacy scripts to legacy/ folder -**Branching Strategy**: Developed in `feature/tui-management`.
- ✅ 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) ## Phase 2: Live Monitoring & Global Configuration
**Goal**: Create modern Python package with proper entry point **Goal**: Add real-time visibility and full system configuration to the TUI.
**Notes**: ### Tasks:
- Used src/ layout for better packaging practices - [ ] **Live Dashboard**:
- Direct Python MQTT integration (no shell script callbacks) - Real-time display of temperature, humidity, and battery levels.
- ATC firmware BLE advertisement reading (passive scanning) - Visual indicators for sensor health/connectivity.
- Watchdog thread for BLE connection recovery - [ ] **Global Configuration**:
- Clean separation of concerns (config, MQTT, sensors, main) - Edit MQTT settings (Host, Port, Credentials).
- Edit application settings (Watchdog, Log Level, etc.).
#### Tasks: - [ ] **System Integration**:
- ✅ Created src/sensorpajen/__init__.py with version info - View service logs within the TUI.
- ✅ Created src/sensorpajen/config.py - Restart/Stop service from the TUI.
- 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) ## Completed Phases
**Goal**: Replace .ini file with JSON and environment variables -**Phase 0: Preparation & Cleanup** (2025-12-27)
-**Phase 0.1: Testing Infrastructure** (2025-12-29)
**Notes**: Templates created in Phase 1, successfully tested on Raspberry Pi -**Phase 1: Modern TUI Management & Data Persistence** (2025-12-29)
#### 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

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 = [ dependencies = [
"pybluez>=0.31", "pybluez",
"bluepy>=1.3.0", "bluepy>=1.3.0",
"paho-mqtt>=1.6.0", "paho-mqtt>=1.6.0",
"textual>=0.40.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -46,6 +47,7 @@ Repository = "https://github.com/yourusername/sensorpajen"
[project.scripts] [project.scripts]
sensorpajen = "sensorpajen.main:main" sensorpajen = "sensorpajen.main:main"
sensorpajen-approve-sensors = "sensorpajen.approve_sensors:main" sensorpajen-approve-sensors = "sensorpajen.approve_sensors:main"
sensorpajen-tui = "sensorpajen.tui.app:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@@ -65,29 +65,39 @@ sudo nano /etc/sensorpajen/sensorpajen.env
sudo systemctl restart sensorpajen 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 ```bash
# Start sensor discovery (if not already running) # Launch the management TUI
sudo systemctl start sensorpajen sudo sensorpajen-tui
# Let it scan for a minute or two to discover sensors
sleep 120
# View discovered sensors and approve them
sudo sensorpajen approve-sensors
``` ```
The approval CLI will: The TUI allows you to:
1. Show newly discovered sensors with their current readings - **Discovery**: View newly discovered sensors and **Approve** (add to monitoring) or **Ignore** them.
2. Ask you to approve, ignore, or skip each sensor - **Configured**: View currently monitored sensors, **Edit** their names, or **Remove** them.
3. Save approved sensors to `/etc/sensorpajen/sensors.json` - **Ignored**: View ignored sensors and **Unignore** them if you change your mind.
4. Mark their status in `/var/lib/sensorpajen/discovered_sensors.json`
**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. 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 ### MQTT Settings
Edit `config/sensorpajen.env`: Edit `config/sensorpajen.env`:

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

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

View File

@@ -9,23 +9,33 @@ import os
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Determine project root and config directory # Determine project root and config directory
# Check if running from system installation (/opt/sensorpajen) or development # Check if running from system installation (/opt/sensorpajen)
_opt_sensorpajen_exists = Path('/opt/sensorpajen').exists() current_file = Path(__file__).resolve()
_var_lib_exists = Path('/var/lib/sensorpajen').exists() is_system_install = str(current_file).startswith('/opt/sensorpajen')
if _opt_sensorpajen_exists: if is_system_install:
# System installation # System installation
PROJECT_ROOT = Path('/opt/sensorpajen') PROJECT_ROOT = Path('/opt/sensorpajen')
CONFIG_DIR = Path('/etc/sensorpajen') CONFIG_DIR = Path('/etc/sensorpajen')
STATE_DIR = Path('/var/lib/sensorpajen') STATE_DIR = Path('/var/lib/sensorpajen')
else: else:
# Development installation (3 levels up from this file: src/sensorpajen/config.py) # 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 PROJECT_ROOT = Path(__file__).parent.parent.parent
CONFIG_DIR = PROJECT_ROOT / "config" CONFIG_DIR = PROJECT_ROOT / "config"
STATE_DIR = CONFIG_DIR STATE_DIR = CONFIG_DIR
@@ -37,7 +47,8 @@ MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen") MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "sensorpajen")
MQTT_TOPIC_PREFIX = os.environ.get("MQTT_TOPIC_PREFIX", "MiTemperature2") MQTT_TOPIC_PREFIX = os.environ.get("MQTT_TOPIC_PREFIX", "MiTemperature2")
# Validate required MQTT configuration def validate_mqtt_config():
"""Validate that required MQTT configuration is present."""
if not MQTT_HOST: if not MQTT_HOST:
raise RuntimeError( raise RuntimeError(
"MQTT_HOST environment variable must be set. " "MQTT_HOST environment variable must be set. "
@@ -70,6 +81,10 @@ DISCOVERED_SENSORS_FILE = os.environ.get(
"DISCOVERED_SENSORS_FILE", "DISCOVERED_SENSORS_FILE",
str(STATE_DIR / "discovered_sensors.json") str(STATE_DIR / "discovered_sensors.json")
) )
DATABASE_FILE = os.environ.get(
"DATABASE_FILE",
str(STATE_DIR / "sensorpajen.db")
)
CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes CONFIG_RELOAD_INTERVAL = int(os.environ.get("CONFIG_RELOAD_INTERVAL", "900")) # 15 minutes
@@ -132,12 +147,142 @@ class SensorConfig:
"""Get list of all configured MAC addresses.""" """Get list of all configured MAC addresses."""
return list(self.sensors.keys()) 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}")
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(): def validate_config():
""" """
Validate configuration and log settings. Validate configuration and log settings.
Should be called at application startup. Should be called at application startup.
""" """
validate_mqtt_config()
install_type = "System" if Path('/opt/sensorpajen').exists() else "Development" install_type = "System" if Path('/opt/sensorpajen').exists() else "Development"
logger.info("=== Sensorpajen Configuration ===") logger.info("=== Sensorpajen Configuration ===")
logger.info(f"Installation Type: {install_type}") 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. Maintains a database of discovered sensors with their metadata and status.
""" """
import json
import logging import logging
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from dataclasses import dataclass, asdict from dataclasses import dataclass
from . import config from . import config
from .db import DatabaseManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,61 +29,45 @@ class DiscoveredSensor:
reviewed: bool = False # Has been shown in approval CLI reviewed: bool = False # Has been shown in approval CLI
ignored_at: Optional[str] = None ignored_at: Optional[str] = None
ignore_reason: Optional[str] = None ignore_reason: Optional[str] = None
count: int = 0
class DiscoveryManager: class DiscoveryManager:
"""Manages discovered sensors and their approval status.""" """Manages discovered sensors and their approval status using SQLite."""
def __init__(self, discovery_file: str = config.DISCOVERED_SENSORS_FILE):
def __init__(self, db_path: str = config.DATABASE_FILE, sensor_config: Optional[config.SensorConfig] = None):
""" """
Initialize discovery manager. Initialize discovery manager.
Args: Args:
discovery_file: Path to discovered sensors JSON file db_path: Path to SQLite database file
sensor_config: Optional reference to SensorConfig to filter pending list
""" """
self.discovery_file = Path(discovery_file) self.db = DatabaseManager(db_path)
self.sensors: Dict[str, DiscoveredSensor] = {} self.db.initialize()
self.load() self.sensor_config = sensor_config
def load(self): def _row_to_sensor(self, row: Dict) -> DiscoveredSensor:
"""Load discovered sensors from JSON file.""" """Convert database row to DiscoveredSensor object."""
if not self.discovery_file.exists(): return DiscoveredSensor(
logger.info(f"Creating new discovered sensors file: {self.discovery_file}") mac=row['mac'],
self.discovery_file.parent.mkdir(parents=True, exist_ok=True) name=row['name'],
self.save() rssi=row['rssi'],
return first_seen=row['first_seen'],
last_seen=row['last_seen'],
try: sample_reading={
with open(self.discovery_file, 'r') as f: "temperature": row['last_temp'],
data = json.load(f) "humidity": row['last_humidity'],
"battery_percent": row['last_battery_percent'],
for sensor_data in data: "battery_voltage": row['last_battery_voltage']
sensor = DiscoveredSensor(**sensor_data) },
self.sensors[sensor.mac.upper()] = sensor status=row['status'],
reviewed=bool(row['reviewed']),
logger.info(f"Loaded {len(self.sensors)} discovered sensors") ignored_at=row['ignored_at'],
ignore_reason=row['ignore_reason'],
except json.JSONDecodeError as e: count=row['count']
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, def add_or_update(self, mac: str, name: str, rssi: int,
temperature: float, humidity: float, temperature: float, humidity: float,
@@ -92,137 +75,92 @@ class DiscoveryManager:
""" """
Add or update a discovered sensor. 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: Returns:
True if this is a newly discovered sensor, False if updated existing True if this is a newly discovered sensor, False if updated existing
""" """
mac = mac.upper() mac = mac.upper()
now = datetime.now().isoformat() existing = self.db.get_sensor(mac)
sample_reading = { self.db.add_or_update_sensor(
"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, mac=mac,
name=name, name=name,
rssi=rssi, rssi=rssi,
first_seen=now, temp=temperature,
last_seen=now, humidity=humidity,
sample_reading=sample_reading, battery_percent=battery_percent,
status="pending" battery_voltage=battery_voltage
) )
self.sensors[mac] = sensor
self.save() if not existing:
logger.info(f"New sensor discovered: {mac} ({name})") logger.info(f"New sensor discovered: {mac} ({name})")
# Send notification for new sensors ONLY if they are not already configured
is_configured = False
if self.sensor_config:
is_configured = mac in self.sensor_config.get_all_macs()
if not is_configured:
sensor = self._row_to_sensor(self.db.get_sensor(mac))
self.send_ntfy_notification(sensor)
else:
logger.debug(f"Sensor {mac} is configured, skipping new sensor notification")
return True return True
return False
def is_known(self, mac: str) -> bool: def is_known(self, mac: str) -> bool:
""" """Check if a sensor has been discovered before."""
Check if a sensor has been discovered before. return self.db.get_sensor(mac) is not None
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]: def get_status(self, mac: str) -> Optional[str]:
""" """Get status of a discovered sensor."""
Get status of a discovered sensor. sensor = self.db.get_sensor(mac)
return sensor['status'] if sensor else None
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): def approve(self, mac: str):
""" """Mark a sensor as approved."""
Mark a sensor as approved. self.db.update_status(mac, "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}") logger.info(f"Sensor approved: {mac}")
def ignore(self, mac: str, reason: Optional[str] = None): def ignore(self, mac: str, reason: Optional[str] = None):
""" """Mark a sensor as ignored."""
Mark a sensor as ignored. self.db.update_status(mac, "ignored", reason)
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}") 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]: def get_pending(self) -> List[DiscoveredSensor]:
"""Get list of sensors with status 'pending'.""" """Get list of sensors with status 'pending'."""
return [s for s in self.sensors.values() if s.status == "pending"] rows = self.db.get_sensors(status="pending")
sensors = [self._row_to_sensor(r) for r in rows]
if self.sensor_config:
# Filter out sensors that are already configured
# (Use MAC from DB row vs keys in sensor_config)
configured_macs = self.sensor_config.get_all_macs()
return [s for s in sensors if s.mac not in configured_macs]
return sensors
def get_new_pending(self) -> List[DiscoveredSensor]: def get_new_pending(self) -> List[DiscoveredSensor]:
"""Get list of pending sensors that haven't been reviewed yet.""" """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]: def get_ignored(self) -> List[DiscoveredSensor]:
"""Get list of sensors with status 'ignored'.""" """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): def mark_reviewed(self, mac: str):
""" """Mark a sensor as reviewed."""
Mark a sensor as reviewed (shown in approval CLI). self.db.mark_reviewed(mac)
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): def send_ntfy_notification(self, sensor: DiscoveredSensor):
""" """Send ntfy notification for a newly discovered sensor."""
Send ntfy notification for a newly discovered sensor.
Args:
sensor: Discovered sensor to notify about
"""
if not config.NTFY_ENABLED: if not config.NTFY_ENABLED:
logger.debug("ntfy notifications disabled") logger.debug("ntfy notifications disabled")
return return

View File

@@ -131,7 +131,7 @@ class Sensorpajen:
# Initialize discovery manager # Initialize discovery manager
self.logger.info("Initializing discovery manager...") self.logger.info("Initializing discovery manager...")
self.discovery_manager = DiscoveryManager() self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config)
# Initialize MQTT publisher # Initialize MQTT publisher
self.logger.info("Initializing MQTT publisher...") self.logger.info("Initializing MQTT publisher...")

View File

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

View File

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

View File

@@ -196,6 +196,19 @@ class SensorReader:
# Create measurement for known sensor # Create measurement for known sensor
sensor_name = self.sensor_config.get_name(mac_with_colons) 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( measurement = Measurement(
temperature=temperature, temperature=temperature,
humidity=humidity, humidity=humidity,
@@ -254,9 +267,8 @@ class SensorReader:
) )
if is_new: if is_new:
logger.info(f"New sensor discovered: {mac} ({device_name})") # Notification is handled by DiscoveryManager
sensor = self.discovery_manager.sensors[mac] pass
self.discovery_manager.send_ntfy_notification(sensor)
def _parse_atc_data(self, data_str: str) -> Optional[tuple]: def _parse_atc_data(self, data_str: str) -> Optional[tuple]:
""" """

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

@@ -0,0 +1,411 @@
from textual.app import App, ComposeResult
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, save_env_var
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;
}
/* 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 = [
("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.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."""
yield Header()
with TabbedContent(initial="discovery"):
with TabPane("Discovery", id="discovery"):
yield DataTable(id="discovery-table", cursor_type="row")
with TabPane("Configured", id="configured"):
yield DataTable(id="configured-table", cursor_type="row")
with TabPane("Ignored", id="ignored"):
yield DataTable(id="ignored-table", cursor_type="row")
with TabPane("Settings", id="settings"):
yield DataTable(id="settings-table", cursor_type="row")
with TabPane("Dashboard", id="dashboard"):
with Horizontal(classes="dashboard-row"):
yield Static("CPU Temp: ...", id="dash-cpu-temp", classes="dash-stat")
yield Static("Load Avg: ...", id="dash-load", classes="dash-stat")
with Horizontal(classes="dashboard-row"):
yield Static("Memory: ...", id="dash-memory", classes="dash-stat")
yield Static("Disk: ...", id="dash-disk", classes="dash-stat")
with Horizontal(classes="dashboard-controls"):
yield Button("Restart Service", id="btn-restart-service", variant="warning")
yield Button("Stop Service", id="btn-stop-service", variant="error")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "btn-restart-service":
self.action_restart_service()
elif event.button.id == "btn-stop-service":
self.action_stop_service()
def action_restart_service(self) -> None:
"""Restart the system service."""
import subprocess
try:
subprocess.run(["sudo", "systemctl", "restart", "sensorpajen"], check=False)
self.notify("Service restart triggered", severity="information")
except Exception as e:
self.notify(f"Failed to restart: {e}", severity="error")
def action_stop_service(self) -> None:
"""Stop the system service."""
import subprocess
try:
subprocess.run(["sudo", "systemctl", "stop", "sensorpajen"], check=False)
self.notify("Service stop triggered", severity="warning")
except Exception as e:
self.notify(f"Failed to stop: {e}", severity="error")
def on_mount(self) -> None:
"""Handle app mount event."""
self.refresh_data()
def 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:
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."""
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)
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 item (sensor or setting)."""
active_tab = self.query_one(TabbedContent).active
if active_tab == "configured":
table = self.query_one("#configured-table", DataTable)
if table.cursor_row is None:
return
row = table.get_row_at(table.cursor_row)
mac = 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."""
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]
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."""
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()
self._update_settings_table()
self._update_dashboard()
def _update_discovery_table(self) -> None:
table = self.query_one("#discovery-table", DataTable)
table.clear(columns=True)
table.add_columns("MAC", "Name", "RSSI", "Last Seen", "Count", "Temp", "Humidity")
sensors = self.discovery_manager.get_pending()
for s in sensors:
table.add_row(
s.mac,
s.name,
str(s.rssi),
s.last_seen.split("T")[1].split(".")[0], # Just time
str(s.count),
f"{s.sample_reading.get('temperature', 0):.1f}°C",
f"{s.sample_reading.get('humidity', 0)}%"
)
def _update_configured_table(self) -> None:
table = self.query_one("#configured-table", DataTable)
table.clear(columns=True)
table.add_columns("MAC", "Name", "Temp", "Humidity", "Battery", "RSSI", "Last Seen")
for mac, name in self.sensor_config.sensors.items():
sensor_data = self.discovery_manager.db.get_sensor(mac)
temp = "N/A"
humidity = "N/A"
battery = "N/A"
rssi = "N/A"
last_seen = "N/A"
if sensor_data:
# sensor_data is a Row/dict
if sensor_data['last_temp'] is not None:
temp = f"{sensor_data['last_temp']:.1f}°C"
if sensor_data['last_humidity'] is not None:
humidity = f"{sensor_data['last_humidity']}%"
if sensor_data['last_battery_percent'] is not None:
battery = f"{sensor_data['last_battery_percent']}%"
if sensor_data['rssi'] is not None:
rssi = str(sensor_data['rssi'])
if sensor_data['last_seen']:
try:
# Extract time only: 2025-12-27T14:30:00 -> 14:30:00
last_seen = sensor_data['last_seen'].split("T")[1].split(".")[0]
except IndexError:
last_seen = sensor_data['last_seen']
table.add_row(mac, name, temp, humidity, battery, rssi, last_seen)
def _update_ignored_table(self) -> None:
table = self.query_one("#ignored-table", DataTable)
table.clear(columns=True)
table.add_columns("MAC", "Name", "Ignored At", "Reason")
sensors = self.discovery_manager.get_ignored()
for s in sensors:
table.add_row(
s.mac,
s.name,
s.ignored_at.split("T")[0] if s.ignored_at else "N/A",
s.ignore_reason or ""
)
def _update_settings_table(self) -> None:
import os
table = self.query_one("#settings-table", DataTable)
table.clear(columns=True)
table.add_columns("Key", "Value")
# Relevant keys to show
relevant_prefixes = ["MQTT_", "WATCHDOG_", "ENABLE_BATTERY", "LOG_LEVEL", "NTFY_", "SKIP_IDENTICAL"]
for key, value in sorted(os.environ.items()):
if any(key.startswith(p) for p in relevant_prefixes):
table.add_row(key, value)
def _update_dashboard(self) -> None:
"""Update dashboard statistics."""
try:
# CPU Temp
cpu_temp = "N/A"
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
temp_raw = int(f.read().strip())
cpu_temp = f"{temp_raw / 1000.0:.1f}°C"
except:
pass
self.query_one("#dash-cpu-temp", Static).update(f"CPU Temp\n{cpu_temp}")
# Load Avg
import os
load_avg = os.getloadavg()
self.query_one("#dash-load", Static).update(f"Load Avg\n{load_avg[0]:.2f}, {load_avg[1]:.2f}, {load_avg[2]:.2f}")
# Memory
mem_used_pct = "N/A"
try:
mem_info = {}
with open("/proc/meminfo", "r") as f:
for line in f:
parts = line.split(":")
if len(parts) == 2:
val = int(parts[1].strip().split()[0])
mem_info[parts[0]] = val
if "MemTotal" in mem_info and "MemAvailable" in mem_info:
total = mem_info["MemTotal"]
avail = mem_info["MemAvailable"]
used = total - avail
mem_used_pct = f"{(used / total) * 100:.1f}%"
except:
pass
self.query_one("#dash-memory", Static).update(f"Memory Used\n{mem_used_pct}")
# Disk
import shutil
disk_used_pct = "N/A"
try:
total, used, free = shutil.disk_usage("/")
disk_used_pct = f"{(used / total) * 100:.1f}%"
except:
pass
self.query_one("#dash-disk", Static).update(f"Disk Usage\n{disk_used_pct}")
except Exception as e:
# Don't crash TUI on stat failure
pass
def main():
app = SensorpajenApp()
app.run()
if __name__ == "__main__":
main()

View File

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