Compare commits
2 Commits
v2.0.0
...
feature/tu
| Author | SHA1 | Date | |
|---|---|---|---|
| 54d55cf0f6 | |||
| cfa24d1fa5 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,8 @@
|
|||||||
.*
|
.*
|
||||||
__pycache__
|
__pycache__
|
||||||
temp
|
temp
|
||||||
|
*.db
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
564
ROADMAP-v2.md
Normal 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
|
||||||
577
ROADMAP.md
577
ROADMAP.md
@@ -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
15
Tasks.md
Normal 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).
|
||||||
@@ -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"]
|
||||||
|
|||||||
40
readme.md
40
readme.md
@@ -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
198
scripts/dev-remote.sh
Executable 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
|
||||||
@@ -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
114
src/sensorpajen/db.py
Normal 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(),))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
|||||||
62
src/sensorpajen/migrate_to_db.py
Normal file
62
src/sensorpajen/migrate_to_db.py
Normal 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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
411
src/sensorpajen/tui/app.py
Normal 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()
|
||||||
33
src/sensorpajen/tui/modals.py
Normal file
33
src/sensorpajen/tui/modals.py
Normal 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
15
tests/conftest.py
Normal 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
67
tests/test_config.py
Normal 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
82
tests/test_db.py
Normal 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"
|
||||||
57
tests/test_discovery_manager.py
Normal file
57
tests/test_discovery_manager.py
Normal 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"
|
||||||
76
tests/test_mqtt_publisher.py
Normal file
76
tests/test_mqtt_publisher.py
Normal 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()
|
||||||
98
tests/test_sensor_reader.py
Normal file
98
tests/test_sensor_reader.py
Normal 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
8
tests/test_tui.py
Normal 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
|
||||||
Reference in New Issue
Block a user