Compare commits
21 Commits
5850089de9
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| fcaaf29307 | |||
| 54d55cf0f6 | |||
| cfa24d1fa5 | |||
| 4213b6101a | |||
| e9b8d56f6d | |||
| a55d065c38 | |||
| eee68e4034 | |||
| c3dc5677b9 | |||
| fc0399a454 | |||
| 85af215d73 | |||
| c5e6187523 | |||
| 4000d0972e | |||
| e1c842b719 | |||
| f2ac55eac1 | |||
| 3e759d30ed | |||
| aeef9a424c | |||
| 36e91c7246 | |||
| 234391a881 | |||
| 427df1f034 | |||
| b467541eb5 | |||
| 16c47e62f5 |
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,3 +1,25 @@
|
|||||||
.*
|
.*
|
||||||
__pycache__
|
__pycache__/
|
||||||
temp
|
temp/
|
||||||
|
*.db
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Local configuration (do not commit secrets or device-specific state)
|
||||||
|
config/sensorpajen.env
|
||||||
|
config/sensors.json
|
||||||
|
config/discovered_sensors.json
|
||||||
|
|
||||||
|
# Packaging build artifacts
|
||||||
|
debian/.debhelper/
|
||||||
|
debian/*.debhelper.log
|
||||||
|
debian/*.log
|
||||||
|
debian/*.substvars
|
||||||
|
debian/debhelper-build-stamp
|
||||||
|
debian/files
|
||||||
|
debian/sensorpajen/
|
||||||
|
|
||||||
|
# Local experiments
|
||||||
|
test-local-tui/
|
||||||
|
|||||||
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
846
COMPLETED_TASKS.md
Normal file
846
COMPLETED_TASKS.md
Normal file
@@ -0,0 +1,846 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## Task: Text UI for sensor management (Phase 1)
|
||||||
|
|
||||||
|
**Status**: DONE (2025-12-29)
|
||||||
|
**Priority**: High
|
||||||
|
**Estimated Effort**: 8-10 hours
|
||||||
|
**Actual Effort**: ~6 hours
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
|
||||||
|
Successfully implemented a modern, full-screen Textual TUI for managing Bluetooth sensors and migrated discovery data to a SQLite database for better persistence and metadata tracking.
|
||||||
|
|
||||||
|
### Key Features Implemented
|
||||||
|
|
||||||
|
✅ **SQLite Database Migration**:
|
||||||
|
- Replaced `discovered_sensors.json` with `discovered_sensors.db`.
|
||||||
|
- Implemented `DatabaseManager` for robust data handling.
|
||||||
|
- Added tracking for RSSI, appearance count, and last seen timestamps.
|
||||||
|
- Created migration script for existing JSON data.
|
||||||
|
|
||||||
|
✅ **Textual TUI Application**:
|
||||||
|
- **Discovery View**: Real-time list of pending sensors with "Approve" and "Ignore" actions.
|
||||||
|
- **Configured View**: Management of `sensors.json` with "Edit" (rename) and "Remove" actions.
|
||||||
|
- **Ignored View**: List of ignored sensors with "Unignore" capability.
|
||||||
|
- **Interactive Modals**: User-friendly dialogs for entering sensor names and ignore reasons.
|
||||||
|
- **Responsive Design**: Full-screen layout with Header, Footer, and Tabbed navigation.
|
||||||
|
|
||||||
|
✅ **Integration & Modernization**:
|
||||||
|
- Added `sensorpajen-tui` entry point for easy access.
|
||||||
|
- Updated `README.md` with TUI usage instructions and keybindings.
|
||||||
|
- Followed TDD approach with unit tests for database and TUI initialization.
|
||||||
|
- Developed in a dedicated `feature/tui-management` branch.
|
||||||
|
|
||||||
|
### Files Created/Modified
|
||||||
|
|
||||||
|
- `src/sensorpajen/db.py`: SQLite database abstraction layer.
|
||||||
|
- `src/sensorpajen/discovery_manager.py`: Refactored to use SQLite.
|
||||||
|
- `src/sensorpajen/tui/app.py`: Main Textual TUI application.
|
||||||
|
- `src/sensorpajen/tui/modals.py`: Modal dialogs for user input.
|
||||||
|
- `src/sensorpajen/migrate_to_db.py`: Migration utility.
|
||||||
|
- `tests/test_db.py`: Unit tests for database logic.
|
||||||
|
- `tests/test_tui.py`: Unit tests for TUI initialization.
|
||||||
|
- `pyproject.toml`: Added `textual` dependency and `sensorpajen-tui` script.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the TUI
|
||||||
|
sensorpajen-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keybindings:**
|
||||||
|
- `a`: Approve selected sensor
|
||||||
|
- `i`: Ignore selected sensor
|
||||||
|
- `e`: Edit sensor name
|
||||||
|
- `u`: Unignore sensor
|
||||||
|
- `Delete`: Remove sensor from monitoring
|
||||||
|
- `r`: Refresh data
|
||||||
|
- `q`: Quit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task: Debian Package Creation
|
||||||
|
|
||||||
|
**Status**: DONE (2025-12-27)
|
||||||
|
**Priority**: Medium
|
||||||
|
**Estimated Effort**: 4-6 hours
|
||||||
|
**Actual Effort**: ~5 hours
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
|
||||||
|
Successfully created a complete Debian package infrastructure for system-wide installation on Raspberry Pi and Debian-based systems. The package provides:
|
||||||
|
|
||||||
|
- **System-wide installation** to `/opt/sensorpajen/` with dedicated user
|
||||||
|
- **Configuration management** via `/etc/sensorpajen/` (preserved on upgrades)
|
||||||
|
- **Automatic setup** including Python venv, dependencies, and Bluetooth capabilities
|
||||||
|
- **Dual-mode operation** supporting both system and development installations
|
||||||
|
- **Build verification** with automated script
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
#### Debian Package Files (debian/)
|
||||||
|
- `control` - Package metadata, dependencies, maintainer info
|
||||||
|
- `compat` - Debhelper compatibility (v13)
|
||||||
|
- `changelog` - Version history and release notes
|
||||||
|
- `rules` - Build instructions (Makefile)
|
||||||
|
- `install` - File installation mappings
|
||||||
|
- `postinst` - Post-installation script (creates user, venv, sets capabilities)
|
||||||
|
- `prerm` - Pre-removal script (stops service)
|
||||||
|
- `postrm` - Post-removal script (cleanup, preserves config)
|
||||||
|
- `sensorpajen.service` - System-wide systemd unit file
|
||||||
|
|
||||||
|
#### Updated Code
|
||||||
|
- `src/sensorpajen/config.py` - Auto-detects system vs development installation
|
||||||
|
- `scripts/approve-sensors.sh` - Supports both installation modes
|
||||||
|
- `scripts/verify-deb.sh` - Automated build and verification script (NEW)
|
||||||
|
|
||||||
|
### Installation Paths
|
||||||
|
|
||||||
|
**System Installation (via .deb):**
|
||||||
|
- Application: `/opt/sensorpajen/`
|
||||||
|
- Python venv: `/opt/sensorpajen/venv/`
|
||||||
|
- Configuration: `/etc/sensorpajen/`
|
||||||
|
- Service: `/etc/systemd/system/sensorpajen.service`
|
||||||
|
- Examples: `/usr/share/doc/sensorpajen/examples/`
|
||||||
|
- User: `sensorpajen` (system account, no login)
|
||||||
|
|
||||||
|
**Development Installation:**
|
||||||
|
- Application: `<project-root>/`
|
||||||
|
- Python venv: `<project-root>/.venv/`
|
||||||
|
- Configuration: `<project-root>/config/`
|
||||||
|
- Service: `~/.config/systemd/user/sensorpajen.service`
|
||||||
|
|
||||||
|
### Key Features Implemented
|
||||||
|
|
||||||
|
✅ System-wide installation with dedicated user
|
||||||
|
✅ Python venv created automatically in postinst
|
||||||
|
✅ All dependencies installed from PyPI
|
||||||
|
✅ Bluetooth capabilities set automatically (setcap)
|
||||||
|
✅ Systemd service enabled but not started (waits for config)
|
||||||
|
✅ Configuration preserved on upgrade/remove/purge
|
||||||
|
✅ Example configs copied to /etc/sensorpajen on first install
|
||||||
|
✅ Dual-mode code (auto-detects system vs dev)
|
||||||
|
✅ Automated verification script
|
||||||
|
✅ Full lintian compliance
|
||||||
|
|
||||||
|
### Build and Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify and build
|
||||||
|
./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 service
|
||||||
|
sudo systemctl start sensorpajen
|
||||||
|
sudo journalctl -u sensorpajen -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Results
|
||||||
|
|
||||||
|
✅ Package builds successfully with `dpkg-buildpackage`
|
||||||
|
✅ Lintian passes without errors (warnings acceptable)
|
||||||
|
✅ Files installed to correct locations
|
||||||
|
✅ System user created automatically
|
||||||
|
✅ Python venv created with all dependencies
|
||||||
|
✅ Bluetooth capabilities set correctly
|
||||||
|
✅ Service enabled but not started before config
|
||||||
|
✅ Configuration preserved on upgrade/remove/purge
|
||||||
|
✅ Service runs as sensorpajen user (not root)
|
||||||
|
✅ Logs appear in `journalctl -u sensorpajen`
|
||||||
|
✅ Dual-mode operation works correctly
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
Create a Debian `.deb` package for system-wide installation of sensorpajen on Raspberry Pi OS and other Debian-based systems. This enables easy distribution and installation via `apt`/`dpkg` instead of manual git clone + pip install.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
1. **System-Wide Installation**
|
||||||
|
- Install application to `/opt/sensorpajen/`
|
||||||
|
- Create Python virtual environment in `/opt/sensorpajen/venv/`
|
||||||
|
- Install systemd service file to `/etc/systemd/system/`
|
||||||
|
- Place configuration in `/etc/sensorpajen/`
|
||||||
|
- Put example configs in `/usr/share/doc/sensorpajen/examples/`
|
||||||
|
|
||||||
|
2. **Dedicated Service User**
|
||||||
|
- Create `sensorpajen` system user if not exists
|
||||||
|
- Service runs as `sensorpajen:sensorpajen`
|
||||||
|
- User has no login shell, no home directory (system account)
|
||||||
|
|
||||||
|
3. **Automatic Service Configuration**
|
||||||
|
- Auto-enable systemd service on installation
|
||||||
|
- Configure Bluetooth capabilities (setcap) automatically
|
||||||
|
- Service starts after installation if config exists
|
||||||
|
|
||||||
|
4. **Configuration Management**
|
||||||
|
- Install example configs to `/usr/share/doc/sensorpajen/examples/`:
|
||||||
|
- `sensorpajen.env.example`
|
||||||
|
- `sensors.json.example`
|
||||||
|
- `discovered_sensors.json.example`
|
||||||
|
- Actual config expected in `/etc/sensorpajen/`:
|
||||||
|
- `sensorpajen.env`
|
||||||
|
- `sensors.json`
|
||||||
|
- Do NOT overwrite existing config on upgrade
|
||||||
|
- Preserve config on package removal
|
||||||
|
- Keep config even on purge (user explicitly chooses)
|
||||||
|
- Postinst should copy the examples into `/etc/sensorpajen/` only if they are missing, leaving any existing config untouched
|
||||||
|
- Upgrades should refresh `/usr/share/doc/sensorpajen/examples/` with new defaults but never alter live configs under `/etc/sensorpajen/`
|
||||||
|
|
||||||
|
5. **Dependency Management**
|
||||||
|
- Depend on system packages: `python3`, `python3-venv`, `python3-pip`, `bluetooth`, `bluez`
|
||||||
|
- Create venv and install Python deps from PyPI in postinst script
|
||||||
|
- Use `pyproject.toml` for Python dependency specification
|
||||||
|
|
||||||
|
6. **Package Metadata**
|
||||||
|
- Package name: `sensorpajen`
|
||||||
|
- Section: `misc`
|
||||||
|
- Priority: `optional`
|
||||||
|
- Architecture: `all`
|
||||||
|
- Maintainer: Fredrik (fredrik@wahlberg.se)
|
||||||
|
- Homepage: Repository URL
|
||||||
|
- Description: "Raspberry Pi Bluetooth temperature sensor monitor"
|
||||||
|
- Depends: System packages
|
||||||
|
- Recommends: `mosquitto-clients` (optional)
|
||||||
|
- **Version Source**: Extract version from `pyproject.toml` during build process.
|
||||||
|
|
||||||
|
7. **Files to Include**
|
||||||
|
- All Python source code from `src/sensorpajen/`
|
||||||
|
- Scripts from `scripts/` (approve-sensors.sh)
|
||||||
|
- Systemd service file (system service, not user service)
|
||||||
|
- Example configuration files
|
||||||
|
- Documentation: `README.md`, `INSTALL.md`
|
||||||
|
- License file
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Package builds successfully with `dpkg-buildpackage -us -uc -b`
|
||||||
|
- [ ] Can install on fresh Raspberry Pi OS with `sudo apt install ./sensorpajen_*.deb`
|
||||||
|
- [ ] Service user `sensorpajen` created automatically
|
||||||
|
- [ ] Python venv created in `/opt/sensorpajen/venv/` with all dependencies
|
||||||
|
- [ ] Bluetooth capabilities set on Python executable
|
||||||
|
- [ ] Systemd service enabled but not started (waits for config)
|
||||||
|
- [ ] After copying examples to `/etc/sensorpajen/` and editing, service starts successfully
|
||||||
|
- [ ] Service runs as `sensorpajen` user, not root
|
||||||
|
- [ ] Logs appear in `journalctl -u sensorpajen`
|
||||||
|
- [ ] Package upgrade preserves `/etc/sensorpajen/` config files
|
||||||
|
- [ ] Package removal (`dpkg -r`) stops service but keeps config
|
||||||
|
- [ ] Package purge (`dpkg -P`) keeps config (user explicitly deletes if wanted)
|
||||||
|
- [ ] `lintian` passes with no errors (warnings acceptable)
|
||||||
|
- [ ] Automated verification script exists that builds the `.deb` and runs `lintian`
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
#### 1. Create `debian/` Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
debian/
|
||||||
|
├── control # Package metadata and dependencies
|
||||||
|
├── rules # Build instructions (Makefile)
|
||||||
|
├── install # Files to install and destinations
|
||||||
|
├── postinst # Post-installation script
|
||||||
|
├── prerm # Pre-removal script
|
||||||
|
├── postrm # Post-removal script
|
||||||
|
├── changelog # Required for native build (minimal entry)
|
||||||
|
└── sensorpajen.service # Systemd service file (system-wide)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `debian/control` File
|
||||||
|
|
||||||
|
```
|
||||||
|
Source: sensorpajen
|
||||||
|
Section: misc
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: Fredrik <fredrik@wahlberg.se>
|
||||||
|
Build-Depends: debhelper-compat (= 13)
|
||||||
|
Standards-Version: 4.5.0
|
||||||
|
Homepage: https://git.example.com/fredrik/sensorpajen
|
||||||
|
|
||||||
|
Package: sensorpajen
|
||||||
|
Architecture: all
|
||||||
|
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, ${misc:Depends}
|
||||||
|
Recommends: mosquitto-clients
|
||||||
|
Description: Raspberry Pi Bluetooth temperature sensor monitor
|
||||||
|
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
|
||||||
|
and publishes readings to MQTT broker. Supports ATC firmware with automatic
|
||||||
|
sensor discovery and approval workflow.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `debian/install` File
|
||||||
|
|
||||||
|
```
|
||||||
|
src/sensorpajen/* opt/sensorpajen/src/sensorpajen/
|
||||||
|
scripts/approve-sensors.sh opt/sensorpajen/scripts/
|
||||||
|
pyproject.toml opt/sensorpajen/
|
||||||
|
README.md usr/share/doc/sensorpajen/
|
||||||
|
INSTALL.md usr/share/doc/sensorpajen/
|
||||||
|
config/*.example usr/share/doc/sensorpajen/examples/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `debian/rules` File
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
#!/usr/bin/make -f
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_build:
|
||||||
|
# No build step needed for pure Python
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
# Installation handled by debian/install file
|
||||||
|
|
||||||
|
override_dh_auto_clean:
|
||||||
|
# Clean build artifacts
|
||||||
|
rm -rf build/ dist/ *.egg-info
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. `debian/postinst` Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create sensorpajen system user
|
||||||
|
if ! getent passwd sensorpajen > /dev/null; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create config directory
|
||||||
|
mkdir -p /etc/sensorpajen
|
||||||
|
chown sensorpajen:sensorpajen /etc/sensorpajen
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
cd /opt/sensorpajen
|
||||||
|
python3 -m venv venv
|
||||||
|
venv/bin/pip install --upgrade pip
|
||||||
|
venv/bin/pip install .
|
||||||
|
|
||||||
|
# Set Bluetooth capabilities
|
||||||
|
PYTHON_PATH=$(readlink -f venv/bin/python3)
|
||||||
|
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || echo "Warning: setcap failed, install libcap2-bin and rerun"
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
cp debian/sensorpajen.service /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable service (but don't start - needs config first)
|
||||||
|
systemctl enable sensorpajen.service || echo "Warning: systemctl enable failed, enable manually"
|
||||||
|
|
||||||
|
# Check if config exists, if so restart service
|
||||||
|
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
|
||||||
|
systemctl restart sensorpajen.service
|
||||||
|
echo "sensorpajen service started"
|
||||||
|
else
|
||||||
|
echo "Configuration needed: Copy examples from /usr/share/doc/sensorpajen/examples/ to /etc/sensorpajen/"
|
||||||
|
echo "Then run: sudo systemctl start sensorpajen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy example configs if they're missing (never overwrite live config)
|
||||||
|
for sample in sensorpajen.env.example sensors.json.example discovered_sensors.json.example; do
|
||||||
|
target="/etc/sensorpajen/${sample%.example}"
|
||||||
|
if [ ! -f "$target" ]; then
|
||||||
|
cp "/usr/share/doc/sensorpajen/examples/$sample" "$target"
|
||||||
|
chown sensorpajen:sensorpajen "$target"
|
||||||
|
echo "Copied $sample to /etc/sensorpajen/"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. `debian/prerm` Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Stop service before removal
|
||||||
|
if systemctl is-active --quiet sensorpajen.service; then
|
||||||
|
systemctl stop sensorpajen.service
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable service
|
||||||
|
systemctl disable sensorpajen.service || true
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. `debian/postrm` Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove)
|
||||||
|
# Service removed but config preserved
|
||||||
|
echo "sensorpajen removed, config preserved in /etc/sensorpajen/"
|
||||||
|
;;
|
||||||
|
purge)
|
||||||
|
# Even on purge, keep config (user choice to delete manually)
|
||||||
|
echo "Config preserved in /etc/sensorpajen/ - delete manually if needed"
|
||||||
|
# Could optionally remove user here, but safer to keep
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Clean up systemd
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. `debian/sensorpajen.service` File
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Sensorpajen Bluetooth Temperature Monitor
|
||||||
|
Documentation=https://github.com/fredrik/sensorpajen
|
||||||
|
After=bluetooth.target network.target
|
||||||
|
Wants=bluetooth.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=sensorpajen
|
||||||
|
Group=sensorpajen
|
||||||
|
WorkingDirectory=/opt/sensorpajen
|
||||||
|
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
|
||||||
|
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Bluetooth capabilities require this to be false
|
||||||
|
NoNewPrivileges=false
|
||||||
|
|
||||||
|
# Hardening (where possible with BT requirements)
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/etc/sensorpajen
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. Build Process
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repository root
|
||||||
|
dpkg-deb --build debian sensorpajen_2.0.0_armhf.deb
|
||||||
|
|
||||||
|
# Check package contents
|
||||||
|
dpkg-deb -c sensorpajen_2.0.0_armhf.deb
|
||||||
|
|
||||||
|
# Check for issues
|
||||||
|
lintian sensorpajen_2.0.0_armhf.deb
|
||||||
|
|
||||||
|
> On every upgrade, rewrite `/usr/share/doc/sensorpajen/examples/` with the new package-provided examples so admins always have the latest defaults, but never overwrite existing files under `/etc/sensorpajen/`.
|
||||||
|
|
||||||
|
### Automated Verification
|
||||||
|
|
||||||
|
Provide a script (e.g., `scripts/verify-deb.sh`) that runs the build and linting steps in a clean environment. The script should:
|
||||||
|
|
||||||
|
```
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
./ci/build-debian.sh # builds the deb into a temp directory
|
||||||
|
lintian sensorpajen_*.deb
|
||||||
|
|
||||||
|
echo "Package verification succeeded"
|
||||||
|
```
|
||||||
|
|
||||||
|
Acceptable tooling: `bash`, `lintian`, `dpkg-deb`. If lintian reports errors, the script should fail and print the diagnostics so you can triage the issue.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 10. Installation Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
sudo dpkg -i sensorpajen_2.0.0_armhf.deb
|
||||||
|
|
||||||
|
# Copy and edit config
|
||||||
|
sudo cp /usr/share/doc/sensorpajen/examples/sensorpajen.env.example /etc/sensorpajen/sensorpajen.env
|
||||||
|
sudo cp /usr/share/doc/sensorpajen/examples/sensors.json.example /etc/sensorpajen/sensors.json
|
||||||
|
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
sudo systemctl start sensorpajen
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status sensorpajen
|
||||||
|
sudo journalctl -u sensorpajen -f
|
||||||
|
|
||||||
|
# Test upgrade
|
||||||
|
# (make changes, rebuild, reinstall - config should persist)
|
||||||
|
|
||||||
|
# Test removal
|
||||||
|
sudo dpkg -r sensorpajen # Config stays
|
||||||
|
sudo dpkg -P sensorpajen # Config still stays
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Paths Reference
|
||||||
|
|
||||||
|
| Purpose | Path |
|
||||||
|
|---------|------|
|
||||||
|
| Application code | `/opt/sensorpajen/src/sensorpajen/` |
|
||||||
|
| Python venv | `/opt/sensorpajen/venv/` |
|
||||||
|
| Scripts | `/opt/sensorpajen/scripts/` |
|
||||||
|
| Systemd service | `/etc/systemd/system/sensorpajen.service` |
|
||||||
|
| Active config | `/etc/sensorpajen/sensorpajen.env`, `/etc/sensorpajen/sensors.json` |
|
||||||
|
| Discovery data | `/etc/sensorpajen/discovered_sensors.json` |
|
||||||
|
| Example configs | `/usr/share/doc/sensorpajen/examples/*.example` |
|
||||||
|
| Documentation | `/usr/share/doc/sensorpajen/` |
|
||||||
|
| Approve script | `/opt/sensorpajen/scripts/approve-sensors.sh` |
|
||||||
|
|
||||||
|
### Configuration Updates Needed
|
||||||
|
|
||||||
|
When implementing, update these to use `/etc/sensorpajen`:
|
||||||
|
|
||||||
|
**`src/sensorpajen/config.py`**:
|
||||||
|
```python
|
||||||
|
# Change PROJECT_ROOT logic for system installation
|
||||||
|
if Path('/opt/sensorpajen').exists():
|
||||||
|
# System installation
|
||||||
|
PROJECT_ROOT = Path('/opt/sensorpajen')
|
||||||
|
CONFIG_DIR = Path('/etc/sensorpajen')
|
||||||
|
else:
|
||||||
|
# Development installation
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||||
|
```
|
||||||
|
|
||||||
|
**`scripts/approve-sensors.sh`**:
|
||||||
|
```bash
|
||||||
|
# Update paths for system installation
|
||||||
|
if [ -d "/opt/sensorpajen" ]; then
|
||||||
|
cd /opt/sensorpajen
|
||||||
|
source /etc/sensorpajen/sensorpajen.env
|
||||||
|
source venv/bin/activate
|
||||||
|
else
|
||||||
|
# Development mode
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
source config/sensorpajen.env
|
||||||
|
source .venv/bin/activate
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Package is **system-wide**, not user-scoped
|
||||||
|
- Config in `/etc/sensorpajen/` is **never** auto-deleted
|
||||||
|
- Service runs as dedicated `sensorpajen` user for security
|
||||||
|
- Virtual environment created post-install to handle PyPI dependencies
|
||||||
|
- Bluetooth capabilities set automatically
|
||||||
|
- Service enabled but not started until config exists
|
||||||
|
- Follow Debian package naming: `sensorpajen_2.0.0_armhf.deb`
|
||||||
|
- Test on fresh Pi before considering complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task: Add Auto-Discovery and Approval Flow for Sensors
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
|
||||||
|
Adding new sensors currently requires manually editing `sensors.json`, which is error-prone and inconvenient.
|
||||||
|
The system should automatically detect new sensors and provide a controlled way for users to approve or ignore them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement **automatic sensor discovery** with a **user approval workflow** that:
|
||||||
|
|
||||||
|
* Detects new sensors automatically
|
||||||
|
* Notifies the user when new sensors are discovered
|
||||||
|
* Allows the user to approve or ignore sensors via a script
|
||||||
|
* Automatically updates `sensors.json` for approved sensors
|
||||||
|
* Restarts the service after configuration changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
* Sensor auto-discovery
|
||||||
|
* Tracking newly discovered sensors
|
||||||
|
* Notification via `ntfy`
|
||||||
|
* Interactive user script for approving/ignoring sensors
|
||||||
|
* Updating `sensors.json`
|
||||||
|
* Restarting the service via systemd
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
* Web UI
|
||||||
|
* Authentication mechanisms beyond existing system access
|
||||||
|
* Changes to sensor hardware or firmware
|
||||||
|
* Long-term sensor management (removal, editing, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### 1. Sensor Auto-Discovery
|
||||||
|
|
||||||
|
* The service must detect sensors that are not present in `sensors.json`
|
||||||
|
* Each newly discovered sensor must have a stable unique identifier
|
||||||
|
* Discovered-but-unapproved sensors must **not** be added automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Discovered Sensor Storage
|
||||||
|
|
||||||
|
* Newly discovered sensors must be stored in `config/discovered_sensors.json`
|
||||||
|
* Stored data must include:
|
||||||
|
* `mac` - MAC address (unique identifier)
|
||||||
|
* `name` - Advertised device name (e.g., "ATC_1234AB")
|
||||||
|
* `rssi` - Signal strength in dBm
|
||||||
|
* `first_seen` - ISO timestamp of first discovery
|
||||||
|
* `last_seen` - ISO timestamp of most recent advertisement
|
||||||
|
* `sample_reading` - One example reading with temperature, humidity, battery data
|
||||||
|
* `status` - One of: "pending", "approved", "ignored"
|
||||||
|
* `ignored_at` - ISO timestamp when ignored (if status is "ignored")
|
||||||
|
* `ignore_reason` - Optional user-provided reason for ignoring
|
||||||
|
* Approved sensors must have their status updated to "approved"
|
||||||
|
* Ignored sensors must remain in the file with status "ignored"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Notification via ntfy
|
||||||
|
|
||||||
|
* When a new sensor is discovered:
|
||||||
|
* Send a notification to the configured `ntfy` topic via curl
|
||||||
|
* Include at least:
|
||||||
|
* Sensor MAC address
|
||||||
|
* Sensor name
|
||||||
|
* Last seen timestamp
|
||||||
|
* Instruction that user action is required
|
||||||
|
* Configuration (in `config/sensorpajen.env`):
|
||||||
|
* `NTFY_ENABLED` - true/false to enable/disable notifications
|
||||||
|
* `NTFY_URL` - ntfy server URL (e.g., "https://ntfy.sh")
|
||||||
|
* `NTFY_TOPIC` - Topic to publish to
|
||||||
|
* `NTFY_TOKEN` - Authentication token (sent in header)
|
||||||
|
* ntfy is optional - system must work without it:
|
||||||
|
* If `NTFY_ENABLED=false`, skip notifications
|
||||||
|
* If ntfy is unreachable, log error and continue
|
||||||
|
* Discovery and approval must work even if ntfy fails
|
||||||
|
* The user must only be notified once per discovered sensor
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. User Approval Script
|
||||||
|
|
||||||
|
Provide a CLI command `sensorpajen approve-sensors` that:
|
||||||
|
|
||||||
|
* Lists all sensors with status "pending" or "ignored"
|
||||||
|
* For each sensor, displays:
|
||||||
|
* MAC address
|
||||||
|
* Advertised name (e.g., "ATC_1234AB")
|
||||||
|
* Last seen timestamp
|
||||||
|
* Sample reading (temperature, humidity, battery)
|
||||||
|
* Current status (pending/ignored)
|
||||||
|
* For each sensor, allows the user to:
|
||||||
|
* Approve the sensor (add to `sensors.json`)
|
||||||
|
* Ignore the sensor (mark as ignored)
|
||||||
|
* Skip (leave as pending for later)
|
||||||
|
* If approving:
|
||||||
|
* Prompt for a sensor name (required, human-readable)
|
||||||
|
* Pre-fill comment field with extended metadata (MAC, device name, last seen, sample reading)
|
||||||
|
* Allow user to edit or keep the pre-filled comment (optional)
|
||||||
|
* If ignoring:
|
||||||
|
* Prompt for optional reason
|
||||||
|
* Update status to "ignored" with timestamp
|
||||||
|
* Interactive mode only (no batch/automated approval)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Updating sensors.json
|
||||||
|
|
||||||
|
* When a sensor is approved:
|
||||||
|
* Add it to `sensors.json` (only if MAC doesn't already exist)
|
||||||
|
* Include:
|
||||||
|
* `mac` - MAC address from discovery
|
||||||
|
* `name` - User-provided human-readable name
|
||||||
|
* `comment` - User-edited comment (pre-filled with metadata)
|
||||||
|
* The file must remain valid JSON
|
||||||
|
* Existing sensors must not be modified
|
||||||
|
* If MAC already exists in `sensors.json`, skip adding (renaming is done manually in the file)
|
||||||
|
* Update status to "approved" in `discovered_sensors.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Configuration Reload
|
||||||
|
|
||||||
|
* The service must automatically reload `sensors.json` every 15 minutes
|
||||||
|
* No service restart required after approval
|
||||||
|
* If `sensors.json` is modified:
|
||||||
|
* Load new sensor list
|
||||||
|
* Start monitoring newly added sensors
|
||||||
|
* Continue monitoring existing sensors without interruption
|
||||||
|
* Log configuration reload events
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
* Must be safe to run on a Raspberry Pi
|
||||||
|
* Must not require a GUI
|
||||||
|
* Must fail gracefully if:
|
||||||
|
|
||||||
|
* `ntfy` is unreachable
|
||||||
|
* The user aborts the approval script
|
||||||
|
* Logging must clearly indicate:
|
||||||
|
|
||||||
|
* Discovery events
|
||||||
|
* Notifications sent
|
||||||
|
* Approval or ignore decisions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
* A new sensor is automatically detected and added to `discovered_sensors.json` with status "pending"
|
||||||
|
* Extended metadata (MAC, name, RSSI, timestamps, sample reading) is stored
|
||||||
|
* A notification is sent via `ntfy` when a sensor is discovered (if enabled)
|
||||||
|
* The approval CLI command (`sensorpajen approve-sensors`) lists pending and ignored sensors
|
||||||
|
* The CLI displays MAC, name, last seen, and sample reading for each sensor
|
||||||
|
* The user can approve a sensor with a custom name
|
||||||
|
* The comment field is pre-filled with metadata and user can edit it
|
||||||
|
* The user can ignore a sensor with an optional reason
|
||||||
|
* Previously ignored sensors can be approved in a later CLI run
|
||||||
|
* Approved sensors appear correctly in `sensors.json` (mac + name + comment only)
|
||||||
|
* Sensors already in `sensors.json` are not added again (no duplicates)
|
||||||
|
* The service automatically reloads `sensors.json` every 15 minutes
|
||||||
|
* New sensors are monitored without service restart
|
||||||
|
* Ignored sensors are stored with `ignored_at` timestamp and optional `ignore_reason`
|
||||||
|
* ntfy failures do not prevent discovery or approval workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Implementation
|
||||||
|
|
||||||
|
* Prefer environment-based configuration (no `.ini` files)
|
||||||
|
* Keep the discovery logic separate from user interaction logic
|
||||||
|
* Avoid race conditions between discovery and approval
|
||||||
|
* Assume multiple sensors may be discovered before user action
|
||||||
|
* Use MAC address as unique identifier for sensors
|
||||||
|
* ntfy notification format: `curl -H "Authorization: Bearer $NTFY_TOKEN" -d "message" $NTFY_URL/$NTFY_TOPIC`
|
||||||
|
* Config reload: Use a timer thread that checks file mtime or reloads every 15 minutes
|
||||||
|
* Pre-filled comment example: `"MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:30:00, Temp: 21.5°C, Humidity: 45%, Battery: 87%"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File Locations
|
||||||
|
* Discovered sensors: `config/discovered_sensors.json`
|
||||||
|
* Known sensors: `config/sensors.json` (existing)
|
||||||
|
* Configuration: `config/sensorpajen.env` (add ntfy settings)
|
||||||
|
|
||||||
|
### New CLI Command
|
||||||
|
* Entry point: `sensorpajen approve-sensors`
|
||||||
|
* Add to `pyproject.toml` under `[project.scripts]`
|
||||||
|
|
||||||
|
### Configuration Variables (add to sensorpajen.env)
|
||||||
|
```bash
|
||||||
|
# ntfy notifications (optional)
|
||||||
|
NTFY_ENABLED=true
|
||||||
|
NTFY_URL=https://ntfy.sh
|
||||||
|
NTFY_TOPIC=sensorpajen
|
||||||
|
NTFY_TOKEN=tk_xxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Config reload interval (seconds)
|
||||||
|
CONFIG_RELOAD_INTERVAL=900 # 15 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
### discovered_sensors.json Structure
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"mac": "A4:C1:38:12:34:56",
|
||||||
|
"name": "ATC_1234AB",
|
||||||
|
"rssi": -65,
|
||||||
|
"first_seen": "2025-12-27T14:30:15",
|
||||||
|
"last_seen": "2025-12-27T14:35:42",
|
||||||
|
"sample_reading": {
|
||||||
|
"temperature": 21.5,
|
||||||
|
"humidity": 45,
|
||||||
|
"battery_percent": 87,
|
||||||
|
"battery_voltage": 2950
|
||||||
|
},
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mac": "A4:C1:38:AB:CD:EF",
|
||||||
|
"name": "ATC_ABCDEF",
|
||||||
|
"rssi": -72,
|
||||||
|
"first_seen": "2025-12-27T15:00:00",
|
||||||
|
"last_seen": "2025-12-27T15:10:00",
|
||||||
|
"sample_reading": {
|
||||||
|
"temperature": 19.8,
|
||||||
|
"humidity": 52,
|
||||||
|
"battery_percent": 65,
|
||||||
|
"battery_voltage": 2800
|
||||||
|
},
|
||||||
|
"status": "ignored",
|
||||||
|
"ignored_at": "2025-12-27T15:15:00",
|
||||||
|
"ignore_reason": "Test sensor, not needed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### sensors.json Entry (after approval)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mac": "A4:C1:38:12:34:56",
|
||||||
|
"name": "Living Room",
|
||||||
|
"comment": "MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:35:42, Temp: 21.5°C, Humidity: 45%, Battery: 87%"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If you want, I can also:
|
||||||
|
|
||||||
|
* Split this into **multiple smaller tasks**
|
||||||
|
* Add a **definition of done** section
|
||||||
|
* Provide a **suggested file/module structure**
|
||||||
|
* Write a **follow-up roadmap entry** for sensor management
|
||||||
|
|
||||||
|
Just tell me how you want to evolve it next.
|
||||||
|
|
||||||
|
## Task: Add tests
|
||||||
|
|
||||||
|
**Status**: DONE (2025-12-29)
|
||||||
|
**Priority**: High
|
||||||
|
**Estimated Effort**: 2-3 hours
|
||||||
|
**Actual Effort**: ~2 hours
|
||||||
|
|
||||||
|
### Implementation Summary
|
||||||
|
Implemented a comprehensive test suite using `pytest` and `pytest-mock`. The tests cover all core modules of the application, ensuring reliability and making future refactoring (like the TUI migration) safer.
|
||||||
|
|
||||||
|
### Key Features Implemented
|
||||||
|
- **Unit Tests for config.py**: Validates environment variable loading, default values, and sensor configuration parsing.
|
||||||
|
- **Unit Tests for mqtt_publisher.py**: Verifies MQTT client initialization, connection handling, and message publishing for all metrics (temp, humidity, battery).
|
||||||
|
- **Unit Tests for sensor_reader.py**: Tests BLE packet handling, ATC format parsing, and measurement creation using mocked Bluetooth hardware.
|
||||||
|
- **Unit Tests for discovery_manager.py**: Ensures discovered sensors are correctly tracked, updated, and persisted to JSON.
|
||||||
|
- **Test Infrastructure**: Added `conftest.py` for global mocks (Bluetooth, Environment) and configured `pyproject.toml` with dev dependencies.
|
||||||
|
|
||||||
|
### Testing Results
|
||||||
|
- ✅ 17 tests passed across 4 test files.
|
||||||
|
- ✅ Mocked all external dependencies (Bluetooth, MQTT Broker, File System).
|
||||||
|
- ✅ Verified correct handling of both known and unknown sensors.
|
||||||
|
|
||||||
211
INSTALL.md
Normal file
211
INSTALL.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Installation Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Raspberry Pi with Raspberry Pi OS (Debian-based)
|
||||||
|
- Python 3.9+
|
||||||
|
- Bluetooth adapter
|
||||||
|
- MQTT broker accessible on network
|
||||||
|
- Xiaomi Mijia LYWSD03MMC sensors with ATC firmware
|
||||||
|
|
||||||
|
## Quick Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone <repo-url> ~/sensorpajen
|
||||||
|
cd ~/sensorpajen
|
||||||
|
|
||||||
|
# Create virtual environment and install
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
cp config/sensorpajen.env.example config/sensorpajen.env
|
||||||
|
cp config/sensors.json.example config/sensors.json
|
||||||
|
nano config/sensorpajen.env # Set MQTT_HOST, credentials
|
||||||
|
nano config/sensors.json # Add sensor MAC addresses
|
||||||
|
|
||||||
|
# Set Bluetooth capabilities
|
||||||
|
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f .venv/bin/python3)
|
||||||
|
|
||||||
|
# Install systemd service
|
||||||
|
mkdir -p ~/.config/systemd/user/
|
||||||
|
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
sudo loginctl enable-linger $USER
|
||||||
|
|
||||||
|
# Enable and start
|
||||||
|
systemctl --user enable sensorpajen
|
||||||
|
systemctl --user start sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check service status
|
||||||
|
systemctl --user status sensorpajen
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
journalctl --user -u sensorpajen -f
|
||||||
|
|
||||||
|
# Test MQTT (adjust credentials)
|
||||||
|
mosquitto_sub -h MQTT_HOST -u USER -P PASSWORD -t "MiTemperature2/#" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### MQTT Settings (`config/sensorpajen.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MQTT_HOST=192.168.1.10 # Required
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER=username
|
||||||
|
MQTT_PASSWORD=password
|
||||||
|
MQTT_CLIENT_ID=sensorpajen
|
||||||
|
MQTT_TOPIC_PREFIX=MiTemperature2
|
||||||
|
|
||||||
|
# Optional: ntfy notifications
|
||||||
|
NTFY_ENABLED=false
|
||||||
|
NTFY_URL=https://ntfy.sh
|
||||||
|
NTFY_TOPIC=sensorpajen
|
||||||
|
NTFY_TOKEN=
|
||||||
|
|
||||||
|
# Tuning
|
||||||
|
WATCHDOG_TIMEOUT=5 # BLE scan restart timeout
|
||||||
|
CONFIG_RELOAD_INTERVAL=900 # Auto-reload sensors (15 min)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensors (`config/sensors.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sensors": [
|
||||||
|
{
|
||||||
|
"mac": "A4:C1:38:12:34:56",
|
||||||
|
"name": "Living Room",
|
||||||
|
"comment": "Optional description"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Sensors
|
||||||
|
|
||||||
|
### Automatic Discovery (Recommended)
|
||||||
|
|
||||||
|
1. Power on new sensor near Raspberry Pi
|
||||||
|
2. Wait for ntfy notification (if enabled) or check logs
|
||||||
|
3. Run approval tool:
|
||||||
|
```bash
|
||||||
|
./scripts/approve-sensors.sh
|
||||||
|
```
|
||||||
|
4. Approve sensor with custom name
|
||||||
|
5. Sensor auto-loaded within 15 minutes
|
||||||
|
|
||||||
|
### Manual Addition
|
||||||
|
|
||||||
|
1. Add to `config/sensors.json`
|
||||||
|
2. Wait 15 minutes for auto-reload, or restart:
|
||||||
|
```bash
|
||||||
|
systemctl --user restart sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start/stop
|
||||||
|
systemctl --user start sensorpajen
|
||||||
|
systemctl --user stop sensorpajen
|
||||||
|
systemctl --user restart sensorpajen
|
||||||
|
|
||||||
|
# Enable/disable autostart
|
||||||
|
systemctl --user enable sensorpajen
|
||||||
|
systemctl --user disable sensorpajen
|
||||||
|
|
||||||
|
# Status and logs
|
||||||
|
systemctl --user status sensorpajen
|
||||||
|
journalctl --user -u sensorpajen -f # Follow
|
||||||
|
journalctl --user -u sensorpajen -n 100 # Last 100 lines
|
||||||
|
journalctl --user -u sensorpajen --since today # Today's logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Permission Denied on Bluetooth
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify capabilities
|
||||||
|
getcap $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||||
|
# Should show: cap_net_admin,cap_net_raw+eip
|
||||||
|
|
||||||
|
# If missing, set them
|
||||||
|
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||||
|
systemctl --user restart sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
### MQTT Connection Failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test MQTT manually
|
||||||
|
mosquitto_sub -h MQTT_HOST -u USER -P PASSWORD -t "test" -v
|
||||||
|
|
||||||
|
# Check credentials in config/sensorpajen.env
|
||||||
|
# Check firewall on MQTT broker
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Sensor Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify sensors visible
|
||||||
|
sudo hcitool lescan
|
||||||
|
|
||||||
|
# Check sensor has ATC firmware (should show as ATC_XXXXXX)
|
||||||
|
# Verify MAC addresses in sensors.json match actual sensors
|
||||||
|
# Check battery level (low battery = intermittent connection)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Won't Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs for errors
|
||||||
|
journalctl --user -u sensorpajen -n 50
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - MQTT_HOST not set in config/sensorpajen.env
|
||||||
|
# - sensors.json syntax error
|
||||||
|
# - Bluetooth service not running: sudo systemctl start bluetooth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/sensorpajen
|
||||||
|
git pull
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Reinstall service if systemd file changed
|
||||||
|
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and disable service
|
||||||
|
systemctl --user stop sensorpajen
|
||||||
|
systemctl --user disable sensorpajen
|
||||||
|
|
||||||
|
# Remove service file
|
||||||
|
rm ~/.config/systemd/user/sensorpajen.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
# Remove capabilities
|
||||||
|
sudo setcap -r $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
||||||
|
|
||||||
|
# Delete repository
|
||||||
|
rm -rf ~/sensorpajen
|
||||||
|
```
|
||||||
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
|
||||||
638
ROADMAP.md
638
ROADMAP.md
@@ -1,624 +1,44 @@
|
|||||||
# 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)
|
||||||
|
- ✅ **Release: v3 Debian package** (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 ✓ TODO
|
|
||||||
**Goal**: Create Debian package for easy installation on Raspberry Pi
|
|
||||||
|
|
||||||
#### Tasks:
|
|
||||||
1. Create debian/ directory structure:
|
|
||||||
```bash
|
|
||||||
mkdir -p debian
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create `debian/control`:
|
|
||||||
``APT package installation instructions
|
|
||||||
- Development installation instructions
|
|
||||||
- Configuration guide (relative paths)
|
|
||||||
- Service management commands
|
|
||||||
- Troubleshooting section
|
|
||||||
- Remove DHT11 references
|
|
||||||
- Remove pirate_audio references
|
|
||||||
|
|
||||||
3. Create INSTALL.md:
|
|
||||||
- APT package installation steps
|
|
||||||
- Manual installation steps
|
|
||||||
- Configuration examples
|
|
||||||
- First-time setup guide
|
|
||||||
- Raspberry Pi specific instructionsds}, ${misc:Depends},
|
|
||||||
python3-bluepy,
|
|
||||||
python3-paho-mqtt,
|
|
||||||
bluetooth,
|
|
||||||
bluez
|
|
||||||
Description: Bluetooth temperature sensor monitor
|
|
||||||
Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
|
|
||||||
and publishes data to MQTT broker.
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Create `debian/rules`:
|
|
||||||
```makefile
|
|
||||||
#!/usr/bin/make -f
|
|
||||||
|
|
||||||
%:
|
|
||||||
dh $@ --with python3 --buildsystem=pybuild
|
|
||||||
|
|
||||||
override_dh_auto_install:
|
|
||||||
pytOption 1: APT Package (Recommended for Raspberry Pi)
|
|
||||||
|
|
||||||
1. Download and install the .deb package:
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i sensorpajen_1.0.0_all.deb
|
|
||||||
sudo apt-get install -f # Fix any dependencies
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Configure:
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/sensorpajen/config
|
|
||||||
cp /usr/share/doc/sensorpajen/examples/sensorpajen.env.example ~/sensorpajen/config/sensorpajen.env
|
|
||||||
cp /usr/share/doc/sensorpajen/examples/sensors.json.example ~/sensorpajen/config/sensors.json
|
|
||||||
# Edit both files
|
|
||||||
nano ~/sensorpajen/config/sensorpajen.env
|
|
||||||
nano ~/sensorpajen/config/sensors.json
|
|
||||||
chmod 600 ~/sensorpajen/config/sensorpajen.env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Enable and start service:
|
|
||||||
```bash
|
|
||||||
systemctl --user enable sensorpajen
|
|
||||||
systemctl --user start sensorpajen
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Development Installation
|
|
||||||
|
|
||||||
1. Clone Repository
|
|
||||||
```bash
|
|
||||||
git clone <repo> ~/sensorpajen
|
|
||||||
cd ~/sensorpajen
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create Virtual Environment
|
|
||||||
```bash
|
|
||||||
python3 -m venv .venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Relative Paths (For Portability)
|
|
||||||
- **Project root**: `~/sensorpajen/` (or wherever you clone/install)
|
|
||||||
- **Application config**: `~/sensorpajen/config/`
|
|
||||||
- **Environment file**: `~/sensorpajen/config/sensorpajen.env` (0600)
|
|
||||||
- **Sensor mapping**: `~/sensorpajen/config/sensors.json` (0644)
|
|
||||||
- **Service file**: `~/.config/systemd/user/sensorpajen.service`
|
|
||||||
|
|
||||||
### Advantages of Relative Paths
|
|
||||||
- Works on any system (development, production, multiple Raspberry Pis)
|
|
||||||
- Easy to backup/restore entire directory
|
|
||||||
- No hardcoded paths in code
|
|
||||||
- Simple to deploy via git pull or package installation
|
|
||||||
- User service runs without sudo
|
|
||||||
|
|
||||||
### APT Package Installation
|
|
||||||
When installed via .deb package:
|
|
||||||
- **Python package**: `/usr/lib/python3/dist-packages/sensorpajen/`
|
|
||||||
- **Service file**: `/lib/systemd/user/sensorpajen.service`
|
|
||||||
- **Config templates**: `/usr/share/doc/sensorpajen/examples/`
|
|
||||||
- **User config**: `~/sensorpajen/config/` (created by user)sensorpajen
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Verify
|
|
||||||
```bash
|
|
||||||
systemctl --user status sensorpajen
|
|
||||||
journalctl --user -u sensorpajen -f
|
|
||||||
```uetooth access
|
|
||||||
if [ "$1" = "configure" ]; then
|
|
||||||
PYTHON_PATH=$(readlink -f /usr/bin/python3)
|
|
||||||
setcap 'cap_net_raw,cap_net_admin+eip' "$PYTHON_PATH" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
#DEBHELPER#
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Create `debian/README.Debian`:
|
|
||||||
- Installation instructions
|
|
||||||
- Configuration guide
|
|
||||||
- Service management
|
|
||||||
|
|
||||||
8. Build the package:
|
|
||||||
```bash
|
|
||||||
dpkg-buildpackage -us -uc -b
|
|
||||||
```
|
|
||||||
|
|
||||||
9. Test installation on Raspberry Pi:
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i ../sensorpajen_1.0.0_all.deb
|
|
||||||
sudo apt-get install -f # Fix dependencies if needed
|
|
||||||
```
|
|
||||||
|
|
||||||
10. Create installation documentation:
|
|
||||||
- Package installation instructions
|
|
||||||
- Configuration setup after installation
|
|
||||||
- Service enablement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 9: Cleanup & Documentation ✓ TODO
|
|
||||||
**Goal**: Remove legacy code and finalize documentation
|
|
||||||
|
|
||||||
#### Tasks:
|
|
||||||
1. Once new service is stable (run for 1-2 weeks):
|
|
||||||
- Delete legacy/ folder
|
|
||||||
- Remove cron jobs completely
|
|
||||||
- Remove tmux session references
|
|
||||||
|
|
||||||
2. Update README.md:
|
|
||||||
- Installation instructions
|
|
||||||
- Configuration guide
|
|
||||||
- Service management commands
|
|
||||||
- Troubleshooting section
|
|
||||||
- Remove DHT11 references
|
|
||||||
- Remove pirate_audio references
|
|
||||||
|
|
||||||
3. Create INSTALL.md:
|
|
||||||
- Fresh installation steps
|
|
||||||
- Configuration examples
|
|
||||||
- First-time setup guide
|
|
||||||
|
|
||||||
4. Document in README:
|
|
||||||
```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
|
|
||||||
|
|||||||
271
TASKS.md
271
TASKS.md
@@ -1,271 +0,0 @@
|
|||||||
# Tasks
|
|
||||||
## Task: Add Auto-Discovery and Approval Flow for Sensors
|
|
||||||
|
|
||||||
### Problem Statement
|
|
||||||
|
|
||||||
Adding new sensors currently requires manually editing `sensors.json`, which is error-prone and inconvenient.
|
|
||||||
The system should automatically detect new sensors and provide a controlled way for users to approve or ignore them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Implement **automatic sensor discovery** with a **user approval workflow** that:
|
|
||||||
|
|
||||||
* Detects new sensors automatically
|
|
||||||
* Notifies the user when new sensors are discovered
|
|
||||||
* Allows the user to approve or ignore sensors via a script
|
|
||||||
* Automatically updates `sensors.json` for approved sensors
|
|
||||||
* Restarts the service after configuration changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### In Scope
|
|
||||||
|
|
||||||
* Sensor auto-discovery
|
|
||||||
* Tracking newly discovered sensors
|
|
||||||
* Notification via `ntfy`
|
|
||||||
* Interactive user script for approving/ignoring sensors
|
|
||||||
* Updating `sensors.json`
|
|
||||||
* Restarting the service via systemd
|
|
||||||
|
|
||||||
### Out of Scope
|
|
||||||
|
|
||||||
* Web UI
|
|
||||||
* Authentication mechanisms beyond existing system access
|
|
||||||
* Changes to sensor hardware or firmware
|
|
||||||
* Long-term sensor management (removal, editing, etc.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Functional Requirements
|
|
||||||
|
|
||||||
### 1. Sensor Auto-Discovery
|
|
||||||
|
|
||||||
* The service must detect sensors that are not present in `sensors.json`
|
|
||||||
* Each newly discovered sensor must have a stable unique identifier
|
|
||||||
* Discovered-but-unapproved sensors must **not** be added automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Discovered Sensor Storage
|
|
||||||
|
|
||||||
* Newly discovered sensors must be stored in `config/discovered_sensors.json`
|
|
||||||
* Stored data must include:
|
|
||||||
* `mac` - MAC address (unique identifier)
|
|
||||||
* `name` - Advertised device name (e.g., "ATC_1234AB")
|
|
||||||
* `rssi` - Signal strength in dBm
|
|
||||||
* `first_seen` - ISO timestamp of first discovery
|
|
||||||
* `last_seen` - ISO timestamp of most recent advertisement
|
|
||||||
* `sample_reading` - One example reading with temperature, humidity, battery data
|
|
||||||
* `status` - One of: "pending", "approved", "ignored"
|
|
||||||
* `ignored_at` - ISO timestamp when ignored (if status is "ignored")
|
|
||||||
* `ignore_reason` - Optional user-provided reason for ignoring
|
|
||||||
* Approved sensors must have their status updated to "approved"
|
|
||||||
* Ignored sensors must remain in the file with status "ignored"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Notification via ntfy
|
|
||||||
|
|
||||||
* When a new sensor is discovered:
|
|
||||||
* Send a notification to the configured `ntfy` topic via curl
|
|
||||||
* Include at least:
|
|
||||||
* Sensor MAC address
|
|
||||||
* Sensor name
|
|
||||||
* Last seen timestamp
|
|
||||||
* Instruction that user action is required
|
|
||||||
* Configuration (in `config/sensorpajen.env`):
|
|
||||||
* `NTFY_ENABLED` - true/false to enable/disable notifications
|
|
||||||
* `NTFY_URL` - ntfy server URL (e.g., "https://ntfy.sh")
|
|
||||||
* `NTFY_TOPIC` - Topic to publish to
|
|
||||||
* `NTFY_TOKEN` - Authentication token (sent in header)
|
|
||||||
* ntfy is optional - system must work without it:
|
|
||||||
* If `NTFY_ENABLED=false`, skip notifications
|
|
||||||
* If ntfy is unreachable, log error and continue
|
|
||||||
* Discovery and approval must work even if ntfy fails
|
|
||||||
* The user must only be notified once per discovered sensor
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. User Approval Script
|
|
||||||
|
|
||||||
Provide a CLI command `sensorpajen approve-sensors` that:
|
|
||||||
|
|
||||||
* Lists all sensors with status "pending" or "ignored"
|
|
||||||
* For each sensor, displays:
|
|
||||||
* MAC address
|
|
||||||
* Advertised name (e.g., "ATC_1234AB")
|
|
||||||
* Last seen timestamp
|
|
||||||
* Sample reading (temperature, humidity, battery)
|
|
||||||
* Current status (pending/ignored)
|
|
||||||
* For each sensor, allows the user to:
|
|
||||||
* Approve the sensor (add to `sensors.json`)
|
|
||||||
* Ignore the sensor (mark as ignored)
|
|
||||||
* Skip (leave as pending for later)
|
|
||||||
* If approving:
|
|
||||||
* Prompt for a sensor name (required, human-readable)
|
|
||||||
* Pre-fill comment field with extended metadata (MAC, device name, last seen, sample reading)
|
|
||||||
* Allow user to edit or keep the pre-filled comment (optional)
|
|
||||||
* If ignoring:
|
|
||||||
* Prompt for optional reason
|
|
||||||
* Update status to "ignored" with timestamp
|
|
||||||
* Interactive mode only (no batch/automated approval)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Updating sensors.json
|
|
||||||
|
|
||||||
* When a sensor is approved:
|
|
||||||
* Add it to `sensors.json` (only if MAC doesn't already exist)
|
|
||||||
* Include:
|
|
||||||
* `mac` - MAC address from discovery
|
|
||||||
* `name` - User-provided human-readable name
|
|
||||||
* `comment` - User-edited comment (pre-filled with metadata)
|
|
||||||
* The file must remain valid JSON
|
|
||||||
* Existing sensors must not be modified
|
|
||||||
* If MAC already exists in `sensors.json`, skip adding (renaming is done manually in the file)
|
|
||||||
* Update status to "approved" in `discovered_sensors.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Configuration Reload
|
|
||||||
|
|
||||||
* The service must automatically reload `sensors.json` every 15 minutes
|
|
||||||
* No service restart required after approval
|
|
||||||
* If `sensors.json` is modified:
|
|
||||||
* Load new sensor list
|
|
||||||
* Start monitoring newly added sensors
|
|
||||||
* Continue monitoring existing sensors without interruption
|
|
||||||
* Log configuration reload events
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
|
||||||
|
|
||||||
* Must be safe to run on a Raspberry Pi
|
|
||||||
* Must not require a GUI
|
|
||||||
* Must fail gracefully if:
|
|
||||||
|
|
||||||
* `ntfy` is unreachable
|
|
||||||
* The user aborts the approval script
|
|
||||||
* Logging must clearly indicate:
|
|
||||||
|
|
||||||
* Discovery events
|
|
||||||
* Notifications sent
|
|
||||||
* Approval or ignore decisions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
* A new sensor is automatically detected and added to `discovered_sensors.json` with status "pending"
|
|
||||||
* Extended metadata (MAC, name, RSSI, timestamps, sample reading) is stored
|
|
||||||
* A notification is sent via `ntfy` when a sensor is discovered (if enabled)
|
|
||||||
* The approval CLI command (`sensorpajen approve-sensors`) lists pending and ignored sensors
|
|
||||||
* The CLI displays MAC, name, last seen, and sample reading for each sensor
|
|
||||||
* The user can approve a sensor with a custom name
|
|
||||||
* The comment field is pre-filled with metadata and user can edit it
|
|
||||||
* The user can ignore a sensor with an optional reason
|
|
||||||
* Previously ignored sensors can be approved in a later CLI run
|
|
||||||
* Approved sensors appear correctly in `sensors.json` (mac + name + comment only)
|
|
||||||
* Sensors already in `sensors.json` are not added again (no duplicates)
|
|
||||||
* The service automatically reloads `sensors.json` every 15 minutes
|
|
||||||
* New sensors are monitored without service restart
|
|
||||||
* Ignored sensors are stored with `ignored_at` timestamp and optional `ignore_reason`
|
|
||||||
* ntfy failures do not prevent discovery or approval workflow
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for Implementation
|
|
||||||
|
|
||||||
* Prefer environment-based configuration (no `.ini` files)
|
|
||||||
* Keep the discovery logic separate from user interaction logic
|
|
||||||
* Avoid race conditions between discovery and approval
|
|
||||||
* Assume multiple sensors may be discovered before user action
|
|
||||||
* Use MAC address as unique identifier for sensors
|
|
||||||
* ntfy notification format: `curl -H "Authorization: Bearer $NTFY_TOKEN" -d "message" $NTFY_URL/$NTFY_TOPIC`
|
|
||||||
* Config reload: Use a timer thread that checks file mtime or reloads every 15 minutes
|
|
||||||
* Pre-filled comment example: `"MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:30:00, Temp: 21.5°C, Humidity: 45%, Battery: 87%"`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### File Locations
|
|
||||||
* Discovered sensors: `config/discovered_sensors.json`
|
|
||||||
* Known sensors: `config/sensors.json` (existing)
|
|
||||||
* Configuration: `config/sensorpajen.env` (add ntfy settings)
|
|
||||||
|
|
||||||
### New CLI Command
|
|
||||||
* Entry point: `sensorpajen approve-sensors`
|
|
||||||
* Add to `pyproject.toml` under `[project.scripts]`
|
|
||||||
|
|
||||||
### Configuration Variables (add to sensorpajen.env)
|
|
||||||
```bash
|
|
||||||
# ntfy notifications (optional)
|
|
||||||
NTFY_ENABLED=true
|
|
||||||
NTFY_URL=https://ntfy.sh
|
|
||||||
NTFY_TOPIC=sensorpajen
|
|
||||||
NTFY_TOKEN=tk_xxxxxxxxxxxxx
|
|
||||||
|
|
||||||
# Config reload interval (seconds)
|
|
||||||
CONFIG_RELOAD_INTERVAL=900 # 15 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
### discovered_sensors.json Structure
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:12:34:56",
|
|
||||||
"name": "ATC_1234AB",
|
|
||||||
"rssi": -65,
|
|
||||||
"first_seen": "2025-12-27T14:30:15",
|
|
||||||
"last_seen": "2025-12-27T14:35:42",
|
|
||||||
"sample_reading": {
|
|
||||||
"temperature": 21.5,
|
|
||||||
"humidity": 45,
|
|
||||||
"battery_percent": 87,
|
|
||||||
"battery_voltage": 2950
|
|
||||||
},
|
|
||||||
"status": "pending"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:AB:CD:EF",
|
|
||||||
"name": "ATC_ABCDEF",
|
|
||||||
"rssi": -72,
|
|
||||||
"first_seen": "2025-12-27T15:00:00",
|
|
||||||
"last_seen": "2025-12-27T15:10:00",
|
|
||||||
"sample_reading": {
|
|
||||||
"temperature": 19.8,
|
|
||||||
"humidity": 52,
|
|
||||||
"battery_percent": 65,
|
|
||||||
"battery_voltage": 2800
|
|
||||||
},
|
|
||||||
"status": "ignored",
|
|
||||||
"ignored_at": "2025-12-27T15:15:00",
|
|
||||||
"ignore_reason": "Test sensor, not needed"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### sensors.json Entry (after approval)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mac": "A4:C1:38:12:34:56",
|
|
||||||
"name": "Living Room",
|
|
||||||
"comment": "MAC: A4:C1:38:12:34:56, Name: ATC_1234AB, Last seen: 2025-12-27T14:35:42, Temp: 21.5°C, Humidity: 45%, Battery: 87%"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
If you want, I can also:
|
|
||||||
|
|
||||||
* Split this into **multiple smaller tasks**
|
|
||||||
* Add a **definition of done** section
|
|
||||||
* Provide a **suggested file/module structure**
|
|
||||||
* Write a **follow-up roadmap entry** for sensor management
|
|
||||||
|
|
||||||
Just tell me how you want to evolve it next.
|
|
||||||
24
Tasks.md
Normal file
24
Tasks.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## Release: v3.0.0 ✅ DONE (2025-12-29)
|
||||||
|
|
||||||
|
**Goal**: Publish a v3 release and Debian package suitable for upgrades.
|
||||||
|
|
||||||
|
### Completed:
|
||||||
|
- ✅ Bump versions to 3.0.0 (Python + Debian changelog)
|
||||||
|
- ✅ Ensure Debian package includes the TUI sources
|
||||||
|
- ✅ Build `sensorpajen_3.0.0_all.deb`
|
||||||
|
|
||||||
|
## Task: TUI Enhancements (Phase 2)
|
||||||
|
|
||||||
|
**Goal**: Add live data, global config, and dashboard.
|
||||||
|
|
||||||
|
### 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).
|
||||||
@@ -5,15 +5,22 @@ MQTT_USER=hasse
|
|||||||
MQTT_PASSWORD=casablanca
|
MQTT_PASSWORD=casablanca
|
||||||
MQTT_CLIENT_ID=mibridge
|
MQTT_CLIENT_ID=mibridge
|
||||||
|
|
||||||
# Sensor Configuration (relative to project root)
|
# Sensor Configuration
|
||||||
SENSOR_CONFIG_FILE=config/sensors.json
|
# For system installation (/opt/sensorpajen): Use absolute paths
|
||||||
DISCOVERED_SENSORS_FILE=config/discovered_sensors.json
|
# SENSOR_CONFIG_FILE=/etc/sensorpajen/sensors.json
|
||||||
|
# DISCOVERED_SENSORS_FILE=/etc/sensorpajen/discovered_sensors.json
|
||||||
|
#
|
||||||
|
# For development installation: Use relative paths (from project root)
|
||||||
|
# SENSOR_CONFIG_FILE=config/sensors.json
|
||||||
|
# DISCOVERED_SENSORS_FILE=config/discovered_sensors.json
|
||||||
|
#
|
||||||
|
# If not set, defaults will be used based on installation type
|
||||||
|
|
||||||
# Application Settings
|
# Application Settings
|
||||||
WATCHDOG_TIMEOUT=5
|
WATCHDOG_TIMEOUT=5
|
||||||
ENABLE_BATTERY=true
|
ENABLE_BATTERY=true
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
CONFIG_RELOAD_INTERVAL=900 # 15 minutes in seconds
|
CONFIG_RELOAD_INTERVAL=900
|
||||||
|
|
||||||
# ntfy Notifications (optional)
|
# ntfy Notifications (optional)
|
||||||
NTFY_ENABLED=false
|
NTFY_ENABLED=false
|
||||||
|
|||||||
364
debian/README.md
vendored
Normal file
364
debian/README.md
vendored
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Debian Package Build Guide
|
||||||
|
|
||||||
|
This directory contains the Debian packaging files for **sensorpajen**, a Bluetooth temperature sensor monitor for Raspberry Pi.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Debian package installs sensorpajen as a **system-wide service** with:
|
||||||
|
|
||||||
|
- Installation to `/opt/sensorpajen/`
|
||||||
|
- Configuration in `/etc/sensorpajen/`
|
||||||
|
- Dedicated `sensorpajen` system user
|
||||||
|
- Systemd service integration
|
||||||
|
- Automatic Python virtual environment setup
|
||||||
|
- Bluetooth capability configuration
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Required Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install \
|
||||||
|
debhelper \
|
||||||
|
dpkg-dev \
|
||||||
|
python3 \
|
||||||
|
python3-venv \
|
||||||
|
python3-pip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional (for verification)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install lintian
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Automated Build and Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
./scripts/verify-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
1. Check for required tools
|
||||||
|
2. Build the package
|
||||||
|
3. Show package contents
|
||||||
|
4. Run lintian checks
|
||||||
|
5. Display installation instructions
|
||||||
|
|
||||||
|
### Manual Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
dpkg-buildpackage -us -uc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.deb` file will be created in the parent directory:
|
||||||
|
```bash
|
||||||
|
ls -lh ../sensorpajen_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Output
|
||||||
|
|
||||||
|
```
|
||||||
|
../sensorpajen_3.0.0_all.deb # Installable package
|
||||||
|
../sensorpajen_3.0.0_armhf.build # Build log
|
||||||
|
../sensorpajen_3.0.0_armhf.buildinfo # Build metadata
|
||||||
|
../sensorpajen_3.0.0_armhf.changes # Changes file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Verification
|
||||||
|
|
||||||
|
### Check Package Contents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dpkg-deb -c ../sensorpajen_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Package Metadata
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dpkg-deb -I ../sensorpajen_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Lintian
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lintian ../sensorpajen_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Warnings are acceptable. Focus on fixing errors.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### On Raspberry Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy package to Pi
|
||||||
|
scp ../sensorpajen_*.deb pi@raspberrypi:~/
|
||||||
|
|
||||||
|
# SSH to Pi and install
|
||||||
|
ssh pi@raspberrypi
|
||||||
|
sudo apt install ./sensorpajen_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Testing (Not Recommended)
|
||||||
|
|
||||||
|
Installing on your development machine will modify `/opt` and `/etc`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ../sensorpajen_*.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning**: This will create system directories and a system user on your dev machine.
|
||||||
|
|
||||||
|
## Post-Installation Configuration
|
||||||
|
|
||||||
|
After installing the package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Edit MQTT credentials
|
||||||
|
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||||
|
|
||||||
|
# 2. Configure sensors
|
||||||
|
sudo nano /etc/sensorpajen/sensors.json
|
||||||
|
|
||||||
|
# 3. Start the service
|
||||||
|
sudo systemctl start sensorpajen
|
||||||
|
|
||||||
|
# 4. Check status
|
||||||
|
sudo systemctl status sensorpajen
|
||||||
|
|
||||||
|
# 5. View logs
|
||||||
|
sudo journalctl -u sensorpajen -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the TUI
|
||||||
|
|
||||||
|
The package installs a `sensorpajen-tui` command in `/usr/bin/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sensorpajen-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally this runs the application from `/opt/sensorpajen/venv/`.
|
||||||
|
|
||||||
|
## Package Structure
|
||||||
|
|
||||||
|
### Installed Files
|
||||||
|
|
||||||
|
| Source | Destination |
|
||||||
|
|--------|-------------|
|
||||||
|
| `src/sensorpajen/*.py` | `/opt/sensorpajen/src/sensorpajen/` |
|
||||||
|
| `src/sensorpajen/tui/*.py` | `/opt/sensorpajen/src/sensorpajen/tui/` |
|
||||||
|
| `scripts/approve-sensors.sh` | `/opt/sensorpajen/scripts/` |
|
||||||
|
| `pyproject.toml` | `/opt/sensorpajen/` |
|
||||||
|
| `README.md`, `INSTALL.md`, `ROADMAP.md` | `/usr/share/doc/sensorpajen/` |
|
||||||
|
| `config/*.example` | `/usr/share/doc/sensorpajen/examples/` |
|
||||||
|
| `debian/sensorpajen.service` | `/etc/systemd/system/` |
|
||||||
|
| *(created in postinst)* | `/opt/sensorpajen/venv/` |
|
||||||
|
| *(created in postinst)* | `/etc/sensorpajen/` |
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
- **Active Config**: `/etc/sensorpajen/sensorpajen.env` (credentials)
|
||||||
|
- **Active Config**: `/etc/sensorpajen/sensors.json` (sensor list)
|
||||||
|
- **Discovery Data**: `/etc/sensorpajen/discovered_sensors.json`
|
||||||
|
- **Examples**: `/usr/share/doc/sensorpajen/examples/*.example`
|
||||||
|
|
||||||
|
## Maintainer Scripts
|
||||||
|
|
||||||
|
### postinst (Post-Installation)
|
||||||
|
|
||||||
|
Runs after package installation:
|
||||||
|
|
||||||
|
1. Creates `sensorpajen` system user (if doesn't exist)
|
||||||
|
2. Creates `/etc/sensorpajen/` directory
|
||||||
|
3. Copies example configs to `/etc/sensorpajen/` (if missing)
|
||||||
|
4. Creates Python virtual environment in `/opt/sensorpajen/venv/`
|
||||||
|
5. Installs Python dependencies via pip
|
||||||
|
6. Sets Bluetooth capabilities on Python executable
|
||||||
|
7. Installs systemd service file
|
||||||
|
8. Enables service (but doesn't start until configured)
|
||||||
|
|
||||||
|
### prerm (Pre-Removal)
|
||||||
|
|
||||||
|
Runs before package removal:
|
||||||
|
|
||||||
|
1. Stops the sensorpajen service
|
||||||
|
2. Disables the service (on remove, not upgrade)
|
||||||
|
|
||||||
|
### postrm (Post-Removal)
|
||||||
|
|
||||||
|
Runs after package removal:
|
||||||
|
|
||||||
|
1. Removes systemd service file
|
||||||
|
2. Reloads systemd daemon
|
||||||
|
3. **Preserves** configuration in `/etc/sensorpajen/`
|
||||||
|
4. **Preserves** `sensorpajen` user
|
||||||
|
|
||||||
|
**Note**: Configuration and user are intentionally preserved to prevent data loss.
|
||||||
|
|
||||||
|
## Upgrade Behavior
|
||||||
|
|
||||||
|
When upgrading to a new version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./sensorpajen_2.1.0_all.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ Service is stopped during upgrade
|
||||||
|
- ✅ Old files are replaced
|
||||||
|
- ✅ Configuration in `/etc/sensorpajen/` is **preserved**
|
||||||
|
- ✅ Python dependencies are updated
|
||||||
|
- ✅ Service is restarted after upgrade
|
||||||
|
- ✅ Example files in `/usr/share/doc/` are updated
|
||||||
|
|
||||||
|
## Removal Behavior
|
||||||
|
|
||||||
|
### Remove (Keep Config)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
- Service stopped and disabled
|
||||||
|
- Application files removed from `/opt/sensorpajen/`
|
||||||
|
- Configuration **preserved** in `/etc/sensorpajen/`
|
||||||
|
- User **preserved**
|
||||||
|
|
||||||
|
### Purge (Still Keeps Config)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt purge sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
- Same as remove
|
||||||
|
- Configuration still **preserved** (by design, for safety)
|
||||||
|
- User still **preserved**
|
||||||
|
|
||||||
|
### Complete Removal
|
||||||
|
|
||||||
|
To completely remove everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt purge sensorpajen
|
||||||
|
sudo rm -rf /etc/sensorpajen
|
||||||
|
sudo userdel sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Build Fails: "debhelper: command not found"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install debhelper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Fails: "dh_python3: command not found"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install dh-python
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lintian Warnings About Permissions
|
||||||
|
|
||||||
|
The postinst script runs as root and sets file permissions. This is expected and safe.
|
||||||
|
|
||||||
|
### Package Won't Install: Dependency Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fix missing dependencies
|
||||||
|
sudo apt install -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Won't Start After Install
|
||||||
|
|
||||||
|
Check if configuration has been edited:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u sensorpajen -n 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Common issues:
|
||||||
|
- MQTT_HOST still has example value
|
||||||
|
- sensors.json is empty
|
||||||
|
- Bluetooth adapter not available
|
||||||
|
|
||||||
|
### Bluetooth Capability Not Set
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manually set capability
|
||||||
|
sudo setcap cap_net_raw,cap_net_admin+eip $(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
getcap $(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Making Changes
|
||||||
|
|
||||||
|
1. Edit source code in `src/sensorpajen/`
|
||||||
|
2. Update version in `pyproject.toml`
|
||||||
|
3. Update `debian/changelog` with new entry
|
||||||
|
4. Rebuild package: `./scripts/verify-deb.sh`
|
||||||
|
5. Test on Raspberry Pi
|
||||||
|
|
||||||
|
### Version Numbering
|
||||||
|
|
||||||
|
- Development: `2.0.0-dev`
|
||||||
|
- Release: `2.0.0`
|
||||||
|
- Patch: `2.0.1`
|
||||||
|
|
||||||
|
Update in both:
|
||||||
|
- `pyproject.toml` (line 6: `version = "..."`)
|
||||||
|
- `debian/changelog` (first line)
|
||||||
|
|
||||||
|
### Testing on Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
./scripts/verify-deb.sh
|
||||||
|
|
||||||
|
# Copy to Pi
|
||||||
|
scp ../sensorpajen_*.deb pi@raspberrypi:~/
|
||||||
|
|
||||||
|
# Install on Pi
|
||||||
|
ssh pi@raspberrypi
|
||||||
|
sudo systemctl stop sensorpajen # If upgrading
|
||||||
|
sudo apt install ./sensorpajen_*.deb
|
||||||
|
sudo systemctl status sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Metadata
|
||||||
|
|
||||||
|
**Package Name**: sensorpajen
|
||||||
|
**Section**: misc
|
||||||
|
**Priority**: optional
|
||||||
|
**Architecture**: all (pure Python)
|
||||||
|
**Maintainer**: Fredrik <fredrik@wahlberg.se>
|
||||||
|
**Depends**: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin
|
||||||
|
**Recommends**: mosquitto-clients
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- **TASKS.md**: Detailed implementation notes
|
||||||
|
- **ROADMAP.md**: Phase 8 section for APT package creation
|
||||||
|
- **INSTALL.md**: User installation guide
|
||||||
|
- **systemd/README.md**: Service management guide
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions:
|
||||||
|
1. Check `sudo journalctl -u sensorpajen -n 100`
|
||||||
|
2. Verify configuration files in `/etc/sensorpajen/`
|
||||||
|
3. Check Bluetooth adapter: `hciconfig`
|
||||||
|
4. Test MQTT connection: `mosquitto_pub -h <host> -t test -m "test"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: December 27, 2025
|
||||||
|
**Package Version**: 2.0.0-dev
|
||||||
28
debian/changelog
vendored
Normal file
28
debian/changelog
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
sensorpajen (3.0.0) stable; urgency=medium
|
||||||
|
|
||||||
|
* Production release v3.0.0
|
||||||
|
* Textual TUI for sensor approval/management
|
||||||
|
* Per-sensor comments persisted in sensors.json
|
||||||
|
* Improved safety UX (delete confirmation, details view)
|
||||||
|
|
||||||
|
-- Fredrik <fredrik@wahlberg.se> Mon, 29 Dec 2025 12:00:00 +0100
|
||||||
|
|
||||||
|
sensorpajen (2.0.0) stable; urgency=medium
|
||||||
|
|
||||||
|
* Production release v2.0.0
|
||||||
|
* Modernized service architecture with systemd
|
||||||
|
* Automatic sensor discovery and approval workflow
|
||||||
|
* Fixed state directory for discovered_sensors.json
|
||||||
|
* Improved documentation with installation guide
|
||||||
|
* Bytecode cleanup in postinst for clean installs
|
||||||
|
|
||||||
|
-- Fredrik <fredrik@wahlberg.se> Sun, 28 Dec 2025 10:56:00 +0100
|
||||||
|
|
||||||
|
sensorpajen (2.0.0-dev) unstable; urgency=medium
|
||||||
|
|
||||||
|
* Initial Debian package release
|
||||||
|
* Modernized service architecture with systemd
|
||||||
|
* Automatic sensor discovery and approval workflow
|
||||||
|
|
||||||
|
-- Fredrik <fredrik@wahlberg.se> Sun, 28 Dec 2025 09:00:00 +0100
|
||||||
|
|
||||||
22
debian/control
vendored
Normal file
22
debian/control
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
Source: sensorpajen
|
||||||
|
Section: misc
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: Fredrik <fredrik@wahlberg.se>
|
||||||
|
Build-Depends: debhelper-compat (= 13)
|
||||||
|
Standards-Version: 4.5.0
|
||||||
|
Homepage: https://github.com/yourusername/sensorpajen
|
||||||
|
|
||||||
|
Package: sensorpajen
|
||||||
|
Architecture: all
|
||||||
|
Depends: python3 (>= 3.9), python3-venv, python3-pip, bluetooth, bluez, libcap2-bin, ${misc:Depends}
|
||||||
|
Recommends: mosquitto-clients
|
||||||
|
Description: Raspberry Pi Bluetooth temperature sensor monitor
|
||||||
|
Monitors Xiaomi Mijia LYWSD03MMC temperature sensors via Bluetooth Low Energy
|
||||||
|
and publishes readings to MQTT broker. Supports ATC firmware with automatic
|
||||||
|
sensor discovery and approval workflow.
|
||||||
|
.
|
||||||
|
Features:
|
||||||
|
- Automatic sensor discovery
|
||||||
|
- MQTT publishing
|
||||||
|
- Systemd service integration
|
||||||
|
- User approval workflow for new sensors
|
||||||
10
debian/install
vendored
Normal file
10
debian/install
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
src/sensorpajen/*.py opt/sensorpajen/src/sensorpajen/
|
||||||
|
src/sensorpajen/tui/*.py opt/sensorpajen/src/sensorpajen/tui/
|
||||||
|
scripts/approve-sensors.sh opt/sensorpajen/scripts/
|
||||||
|
debian/sensorpajen-tui usr/bin/
|
||||||
|
pyproject.toml opt/sensorpajen/
|
||||||
|
requirements.txt opt/sensorpajen/
|
||||||
|
readme.md usr/share/doc/sensorpajen/
|
||||||
|
INSTALL.md usr/share/doc/sensorpajen/
|
||||||
|
ROADMAP.md usr/share/doc/sensorpajen/
|
||||||
|
config/*.example usr/share/doc/sensorpajen/examples/
|
||||||
161
debian/postinst
vendored
Executable file
161
debian/postinst
vendored
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
# Create sensorpajen system user if it doesn't exist
|
||||||
|
if ! getent passwd sensorpajen > /dev/null; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin sensorpajen
|
||||||
|
echo "Created system user: sensorpajen"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create config directory with proper permissions
|
||||||
|
mkdir -p /etc/sensorpajen
|
||||||
|
chown sensorpajen:sensorpajen /etc/sensorpajen
|
||||||
|
chmod 750 /etc/sensorpajen
|
||||||
|
|
||||||
|
# Create state directory with proper permissions (writable at runtime)
|
||||||
|
mkdir -p /var/lib/sensorpajen
|
||||||
|
chown sensorpajen:sensorpajen /var/lib/sensorpajen
|
||||||
|
chmod 750 /var/lib/sensorpajen
|
||||||
|
|
||||||
|
# Copy example configs to /etc/sensorpajen if they don't exist
|
||||||
|
for sample in sensorpajen.env.example sensors.json.example; do
|
||||||
|
source_file="/usr/share/doc/sensorpajen/examples/$sample"
|
||||||
|
target_file="/etc/sensorpajen/${sample%.example}"
|
||||||
|
|
||||||
|
if [ -f "$source_file" ] && [ ! -f "$target_file" ]; then
|
||||||
|
cp "$source_file" "$target_file"
|
||||||
|
chown sensorpajen:sensorpajen "$target_file"
|
||||||
|
|
||||||
|
# Set restrictive permissions on env file (contains credentials)
|
||||||
|
if [ "$sample" = "sensorpajen.env.example" ]; then
|
||||||
|
chmod 600 "$target_file"
|
||||||
|
echo "Created $target_file (edit this file with your MQTT credentials)"
|
||||||
|
else
|
||||||
|
chmod 640 "$target_file"
|
||||||
|
echo "Created $target_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Create virtual environment in /opt/sensorpajen
|
||||||
|
cd /opt/sensorpajen
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "Creating Python virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
venv/bin/pip install --upgrade pip setuptools wheel
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Python dependencies from requirements.txt
|
||||||
|
echo "Installing Python dependencies..."
|
||||||
|
if [ -f "/opt/sensorpajen/requirements.txt" ]; then
|
||||||
|
venv/bin/pip install -r /opt/sensorpajen/requirements.txt
|
||||||
|
else
|
||||||
|
echo "Warning: requirements.txt not found, installing bluepy and paho-mqtt directly"
|
||||||
|
venv/bin/pip install bluepy paho-mqtt pybluez
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to install dependencies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install sensorpajen package itself
|
||||||
|
echo "Installing sensorpajen application..."
|
||||||
|
cd /opt/sensorpajen
|
||||||
|
# Clean up any stale bytecode before building wheel
|
||||||
|
find . -name "*.pyc" -delete
|
||||||
|
find . -name "__pycache__" -type d -delete
|
||||||
|
venv/bin/pip install --no-deps . || {
|
||||||
|
echo "Error: Failed to install sensorpajen package"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
cd /
|
||||||
|
|
||||||
|
# Set ownership of application directory BEFORE setting capabilities
|
||||||
|
chown -R sensorpajen:sensorpajen /opt/sensorpajen
|
||||||
|
|
||||||
|
# Set Bluetooth capabilities on Python executable (after ownership change)
|
||||||
|
PYTHON_PATH=$(readlink -f /opt/sensorpajen/venv/bin/python3)
|
||||||
|
if command -v setcap >/dev/null 2>&1; then
|
||||||
|
setcap cap_net_raw,cap_net_admin+eip "$PYTHON_PATH" || {
|
||||||
|
echo "Warning: setcap failed. You may need to run Bluetooth operations as root."
|
||||||
|
echo "Try: sudo setcap cap_net_raw,cap_net_admin+eip $PYTHON_PATH"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "Warning: setcap not found (install libcap2-bin package)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# v2 installed a unit into /etc/systemd/system/, which overrides packaged units
|
||||||
|
# and prevents upgrades from taking effect. If that file exists and is identical
|
||||||
|
# to the packaged unit, remove the override.
|
||||||
|
if [ -f /etc/systemd/system/sensorpajen.service ]; then
|
||||||
|
PACKAGED_UNIT=""
|
||||||
|
if [ -f /lib/systemd/system/sensorpajen.service ]; then
|
||||||
|
PACKAGED_UNIT="/lib/systemd/system/sensorpajen.service"
|
||||||
|
elif [ -f /usr/lib/systemd/system/sensorpajen.service ]; then
|
||||||
|
PACKAGED_UNIT="/usr/lib/systemd/system/sensorpajen.service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PACKAGED_UNIT" ] && diff -q /etc/systemd/system/sensorpajen.service "$PACKAGED_UNIT" >/dev/null 2>&1; then
|
||||||
|
rm -f /etc/systemd/system/sensorpajen.service
|
||||||
|
echo "Removed redundant /etc override unit (upgrade-safe)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reload systemd
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Enable service (but don't start - needs configuration first)
|
||||||
|
systemctl enable sensorpajen.service || {
|
||||||
|
echo "Warning: Could not enable sensorpajen service"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if configuration is ready
|
||||||
|
if [ -f /etc/sensorpajen/sensorpajen.env ] && [ -f /etc/sensorpajen/sensors.json ]; then
|
||||||
|
# Check if env file has been configured (not default values)
|
||||||
|
if grep -q "MQTT_HOST=192.168.0.114" /etc/sensorpajen/sensorpajen.env; then
|
||||||
|
echo ""
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Configuration needed!"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Edit /etc/sensorpajen/sensorpajen.env with your MQTT settings"
|
||||||
|
echo " Edit /etc/sensorpajen/sensors.json with your sensor list"
|
||||||
|
echo " Then run: sudo systemctl start sensorpajen"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
# Configuration appears to be customized, restart service
|
||||||
|
systemctl restart sensorpajen.service && {
|
||||||
|
echo "Sensorpajen service started"
|
||||||
|
echo "View logs: sudo journalctl -u sensorpajen -f"
|
||||||
|
} || {
|
||||||
|
echo "Failed to start service. Check: sudo systemctl status sensorpajen"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Sensorpajen installed successfully!"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Next steps:"
|
||||||
|
echo " 1. Edit /etc/sensorpajen/sensorpajen.env"
|
||||||
|
echo " 2. Edit /etc/sensorpajen/sensors.json"
|
||||||
|
echo " 3. sudo systemctl start sensorpajen"
|
||||||
|
echo " 4. sudo journalctl -u sensorpajen -f"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
abort-upgrade|abort-remove|abort-deconfigure)
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "postinst called with unknown argument \`$1'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
41
debian/postrm
vendored
Executable file
41
debian/postrm
vendored
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove)
|
||||||
|
# Service removed but config and user preserved
|
||||||
|
echo "Sensorpajen removed. Configuration preserved in /etc/sensorpajen/"
|
||||||
|
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
|
||||||
|
|
||||||
|
# Remove systemd service file
|
||||||
|
rm -f /etc/systemd/system/sensorpajen.service
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
;;
|
||||||
|
|
||||||
|
purge)
|
||||||
|
# Even on purge, we keep config by default (user can manually delete)
|
||||||
|
# This is safer as it prevents accidental data loss
|
||||||
|
echo "Configuration preserved in /etc/sensorpajen/"
|
||||||
|
echo "To remove config: sudo rm -rf /etc/sensorpajen/"
|
||||||
|
echo "To remove user: sudo userdel sensorpajen"
|
||||||
|
|
||||||
|
# Remove systemd service file
|
||||||
|
rm -f /etc/systemd/system/sensorpajen.service
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
|
||||||
|
# Note: We intentionally do NOT remove:
|
||||||
|
# - /etc/sensorpajen (contains user data)
|
||||||
|
# - sensorpajen user (may own other files/processes)
|
||||||
|
# User must remove these manually if desired
|
||||||
|
;;
|
||||||
|
|
||||||
|
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "postrm called with unknown argument \`$1'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
27
debian/prerm
vendored
Executable file
27
debian/prerm
vendored
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
# Stop service before removal or upgrade
|
||||||
|
if systemctl is-active --quiet sensorpajen.service 2>/dev/null; then
|
||||||
|
echo "Stopping sensorpajen service..."
|
||||||
|
systemctl stop sensorpajen.service || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Disable service on removal (not upgrade)
|
||||||
|
if [ "$1" = "remove" ]; then
|
||||||
|
systemctl disable sensorpajen.service || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
failed-upgrade)
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "prerm called with unknown argument \`$1'" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
20
debian/rules
vendored
Executable file
20
debian/rules
vendored
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/make -f
|
||||||
|
|
||||||
|
%:
|
||||||
|
dh $@
|
||||||
|
|
||||||
|
override_dh_auto_build:
|
||||||
|
# No build step needed for pure Python
|
||||||
|
|
||||||
|
override_dh_auto_install:
|
||||||
|
# Installation handled by debian/install file
|
||||||
|
dh_auto_install
|
||||||
|
|
||||||
|
override_dh_auto_clean:
|
||||||
|
# Clean build artifacts
|
||||||
|
rm -rf build/ dist/ *.egg-info
|
||||||
|
rm -rf src/*.egg-info
|
||||||
|
|
||||||
|
override_dh_builddeb:
|
||||||
|
# Use gzip compression for better compatibility
|
||||||
|
dh_builddeb -- -Zgzip
|
||||||
12
debian/sensorpajen-tui
vendored
Executable file
12
debian/sensorpajen-tui
vendored
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Wrapper to run the installed TUI using the app's virtualenv.
|
||||||
|
# The venv is created/updated by the package postinst.
|
||||||
|
|
||||||
|
if [ -x /opt/sensorpajen/venv/bin/sensorpajen-tui ]; then
|
||||||
|
exec /opt/sensorpajen/venv/bin/sensorpajen-tui "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback (should normally not be needed)
|
||||||
|
exec /opt/sensorpajen/venv/bin/python -m sensorpajen.tui.app "$@"
|
||||||
1
debian/sensorpajen.debhelper.log
vendored
Normal file
1
debian/sensorpajen.debhelper.log
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dh_builddeb
|
||||||
32
debian/sensorpajen.service
vendored
Normal file
32
debian/sensorpajen.service
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Sensorpajen - Bluetooth Temperature Sensor Monitor
|
||||||
|
Documentation=https://github.com/yourusername/sensorpajen
|
||||||
|
After=network.target bluetooth.target
|
||||||
|
Wants=bluetooth.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=sensorpajen
|
||||||
|
Group=sensorpajen
|
||||||
|
WorkingDirectory=/opt/sensorpajen
|
||||||
|
EnvironmentFile=/etc/sensorpajen/sensorpajen.env
|
||||||
|
ExecStart=/opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Bluetooth capabilities require this to be false
|
||||||
|
NoNewPrivileges=false
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=sensorpajen
|
||||||
|
|
||||||
|
# Security hardening (where possible with Bluetooth requirements)
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/etc/sensorpajen /var/lib/sensorpajen
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
#!/usr/bin/python3 -u
|
|
||||||
#!/home/openhabian/Python3/Python-3.7.4/python -u
|
|
||||||
#-u to unbuffer output. Otherwise when calling with nohup or redirecting output things are printed very lately or would even mixup
|
|
||||||
|
|
||||||
from bluepy import btle
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from collections import deque
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import signal
|
|
||||||
import traceback
|
|
||||||
import math
|
|
||||||
import logging
|
|
||||||
import paho.mqtt.client as mqtt
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Measurement:
|
|
||||||
temperature: float
|
|
||||||
humidity: int
|
|
||||||
voltage: float
|
|
||||||
calibratedHumidity: int = 0
|
|
||||||
battery: int = 0
|
|
||||||
timestamp: int = 0
|
|
||||||
sensorname: str = ""
|
|
||||||
rssi: int = 0
|
|
||||||
|
|
||||||
def __eq__(self, other): #rssi may be different
|
|
||||||
if self.temperature == other.temperature and self.humidity == other.humidity and self.calibratedHumidity == other.calibratedHumidity and self.battery == other.battery and self.voltage == other.voltage and self.sensorname == other.sensorname:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
measurements=deque()
|
|
||||||
#globalBatteryLevel=0
|
|
||||||
previousMeasurement=Measurement(0,0,0,0,0,0,0,0)
|
|
||||||
identicalCounter=0
|
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
|
||||||
if args.atc:
|
|
||||||
disable_le_scan(sock)
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
def watchDog_Thread():
|
|
||||||
global unconnectedTime
|
|
||||||
global connected
|
|
||||||
global pid
|
|
||||||
while True:
|
|
||||||
logging.debug("watchdog_Thread")
|
|
||||||
logging.debug("unconnectedTime : " + str(unconnectedTime))
|
|
||||||
logging.debug("connected : " + str(connected))
|
|
||||||
logging.debug("pid : " + str(pid))
|
|
||||||
now = int(time.time())
|
|
||||||
if (unconnectedTime is not None) and ((now - unconnectedTime) > 60): #could also check connected is False, but this is more fault proof
|
|
||||||
pstree=os.popen("pstree -p " + str(pid)).read() #we want to kill only bluepy from our own process tree, because other python scripts have there own bluepy-helper process
|
|
||||||
logging.debug("PSTree: " + pstree)
|
|
||||||
try:
|
|
||||||
bluepypid=re.findall(r'bluepy-helper\((.*)\)',pstree)[0] #Store the bluepypid, to kill it later
|
|
||||||
except IndexError: #Should not happen since we're now connected
|
|
||||||
logging.debug("Couldn't find pid of bluepy-helper")
|
|
||||||
os.system("kill " + bluepypid)
|
|
||||||
logging.debug("Killed bluepy with pid: " + str(bluepypid))
|
|
||||||
unconnectedTime = now #reset unconnectedTime to prevent multiple killings in a row
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
|
|
||||||
def thread_SendingData():
|
|
||||||
global previousMeasurement
|
|
||||||
global measurements
|
|
||||||
path = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
mea = measurements.popleft()
|
|
||||||
if (mea == previousMeasurement and identicalCounter < args.skipidentical): #only send data when it has changed or X identical data has been skipped, ~10 pakets per minute, 50 pakets --> writing at least every 5 minutes
|
|
||||||
print("Measurements are identical don't send data\n")
|
|
||||||
identicalCounter+=1
|
|
||||||
continue
|
|
||||||
identicalCounter=0
|
|
||||||
fmt = "sensorname,temperature,humidity,voltage" #don't try to seperate by semicolon ';' os.system will use that as command seperator
|
|
||||||
if ' ' in mea.sensorname:
|
|
||||||
sensorname = '"' + mea.sensorname + '"'
|
|
||||||
else:
|
|
||||||
sensorname = mea.sensorname
|
|
||||||
params = sensorname + " " + str(mea.temperature) + " " + str(mea.humidity) + " " + str(mea.voltage)
|
|
||||||
if (args.TwoPointCalibration or args.offset): #would be more efficient to generate fmt only once
|
|
||||||
fmt +=",humidityCalibrated"
|
|
||||||
params += " " + str(mea.calibratedHumidity)
|
|
||||||
if (args.battery):
|
|
||||||
fmt +=",batteryLevel"
|
|
||||||
params += " " + str(mea.battery)
|
|
||||||
if (args.rssi):
|
|
||||||
fmt +=",rssi"
|
|
||||||
params += " " + str(mea.rssi)
|
|
||||||
params += " " + str(mea.timestamp)
|
|
||||||
fmt +=",timestamp"
|
|
||||||
|
|
||||||
#""" cmd = path + "/" + args.callback + " " + fmt + " " + params
|
|
||||||
# print(cmd)
|
|
||||||
# ret = os.system(cmd)./L
|
|
||||||
# if (ret != 0):
|
|
||||||
# measurements.appendleft(mea) #put the measurement back
|
|
||||||
# print ("Data couln't be send to Callback, retrying...")
|
|
||||||
# time.sleep(5) #wait before trying again
|
|
||||||
# else: #data was sent
|
|
||||||
# previousMeasurement=Measurement(mea.temperature,mea.humidity,mea.voltage,mea.calibratedHumidity,mea.battery,0) #using copy or deepcopy requires implementation in the class definition
|
|
||||||
#"""
|
|
||||||
except IndexError:
|
|
||||||
#print("Keine Daten")
|
|
||||||
time.sleep(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print(traceback.format_exc())
|
|
||||||
|
|
||||||
sock = None #from ATC
|
|
||||||
lastBLEPaketReceived = 0
|
|
||||||
BLERestartCounter = 1
|
|
||||||
def keepingLEScanRunning(): #LE-Scanning gets disabled sometimes, especially if you have a lot of BLE connections, this thread periodically enables BLE scanning again
|
|
||||||
global BLERestartCounter
|
|
||||||
while True:
|
|
||||||
time.sleep(1)
|
|
||||||
now = time.time()
|
|
||||||
if now - lastBLEPaketReceived > args.watchdogtimer:
|
|
||||||
print("Watchdog: Did not receive any BLE Paket within", int(now - lastBLEPaketReceived), "s. Restarting BLE scan. Count:", BLERestartCounter)
|
|
||||||
disable_le_scan(sock)
|
|
||||||
enable_le_scan(sock, filter_duplicates=False)
|
|
||||||
BLERestartCounter += 1
|
|
||||||
print("")
|
|
||||||
time.sleep(5) #give some time to take effect
|
|
||||||
|
|
||||||
|
|
||||||
def calibrateHumidity2Points(humidity, offset1, offset2, calpoint1, calpoint2):
|
|
||||||
#offset1=args.offset1
|
|
||||||
#offset2=args.offset2
|
|
||||||
#p1y=args.calpoint1
|
|
||||||
#p2y=args.calpoint2
|
|
||||||
p1y=calpoint1
|
|
||||||
p2y=calpoint2
|
|
||||||
p1x=p1y - offset1
|
|
||||||
p2x=p2y - offset2
|
|
||||||
m = (p1y - p2y) * 1.0 / (p1x - p2x) # y=mx+b
|
|
||||||
#b = (p1x * p2y - p2x * p1y) * 1.0 / (p1y - p2y)
|
|
||||||
b = p2y - m * p2x #would be more efficient to do this calculations only once
|
|
||||||
humidityCalibrated=m*humidity + b
|
|
||||||
if (humidityCalibrated > 100 ): #with correct calibration this should not happen
|
|
||||||
humidityCalibrated = 100
|
|
||||||
elif (humidityCalibrated < 0):
|
|
||||||
humidityCalibrated = 0
|
|
||||||
humidityCalibrated=int(round(humidityCalibrated,0))
|
|
||||||
return humidityCalibrated
|
|
||||||
|
|
||||||
|
|
||||||
mode="round"
|
|
||||||
class MyDelegate(btle.DefaultDelegate):
|
|
||||||
def __init__(self, params):
|
|
||||||
btle.DefaultDelegate.__init__(self)
|
|
||||||
# ... initialise here
|
|
||||||
|
|
||||||
def handleNotification(self, cHandle, data):
|
|
||||||
global measurements
|
|
||||||
try:
|
|
||||||
measurement = Measurement(0,0,0,0,0,0,0,0)
|
|
||||||
if args.influxdb == 1:
|
|
||||||
measurement.timestamp = int((time.time() // 10) * 10)
|
|
||||||
else:
|
|
||||||
measurement.timestamp = int(time.time())
|
|
||||||
temp=int.from_bytes(data[0:2],byteorder='little',signed=True)/100
|
|
||||||
#print("Temp received: " + str(temp))
|
|
||||||
if args.round:
|
|
||||||
#print("Temperatur unrounded: " + str(temp
|
|
||||||
|
|
||||||
if args.debounce:
|
|
||||||
global mode
|
|
||||||
temp*=10
|
|
||||||
intpart = math.floor(temp)
|
|
||||||
fracpart = round(temp - intpart,1)
|
|
||||||
#print("Fracpart: " + str(fracpart))
|
|
||||||
if fracpart >= 0.7:
|
|
||||||
mode="ceil"
|
|
||||||
elif fracpart <= 0.2: #either 0.8 and 0.3 or 0.7 and 0.2 for best even distribution
|
|
||||||
mode="trunc"
|
|
||||||
#print("Modus: " + mode)
|
|
||||||
if mode=="trunc": #only a few times
|
|
||||||
temp=math.trunc(temp)
|
|
||||||
elif mode=="ceil":
|
|
||||||
temp=math.ceil(temp)
|
|
||||||
else:
|
|
||||||
temp=round(temp,0)
|
|
||||||
temp /=10.
|
|
||||||
#print("Debounced temp: " + str(temp))
|
|
||||||
else:
|
|
||||||
temp=round(temp,1)
|
|
||||||
humidity=int.from_bytes(data[2:3],byteorder='little')
|
|
||||||
print("Temperature: " + str(temp))
|
|
||||||
print("Humidity: " + str(humidity))
|
|
||||||
voltage=int.from_bytes(data[3:5],byteorder='little') / 1000.
|
|
||||||
print("Battery voltage:",voltage,"V")
|
|
||||||
measurement.temperature = temp
|
|
||||||
measurement.humidity = humidity
|
|
||||||
measurement.voltage = voltage
|
|
||||||
measurement.sensorname = args.name
|
|
||||||
if args.battery:
|
|
||||||
#measurement.battery = globalBatteryLevel
|
|
||||||
batteryLevel = min(int(round((voltage - 2.1),2) * 100), 100) #3.1 or above --> 100% 2.1 --> 0 %
|
|
||||||
measurement.battery = batteryLevel
|
|
||||||
print("Battery level:",batteryLevel)
|
|
||||||
|
|
||||||
|
|
||||||
if args.offset:
|
|
||||||
humidityCalibrated = humidity + args.offset
|
|
||||||
print("Calibrated humidity: " + str(humidityCalibrated))
|
|
||||||
measurement.calibratedHumidity = humidityCalibrated
|
|
||||||
|
|
||||||
if args.TwoPointCalibration:
|
|
||||||
humidityCalibrated= calibrateHumidity2Points(humidity,args.offset1,args.offset2, args.calpoint1, args.calpoint2)
|
|
||||||
print("Calibrated humidity: " + str(humidityCalibrated))
|
|
||||||
measurement.calibratedHumidity = humidityCalibrated
|
|
||||||
|
|
||||||
if(args.callback):
|
|
||||||
measurements.append(measurement)
|
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("Fehler")
|
|
||||||
print(e)
|
|
||||||
print(traceback.format_exc())
|
|
||||||
|
|
||||||
# Initialisation -------
|
|
||||||
|
|
||||||
def connect():
|
|
||||||
#print("Interface: " + str(args.interface))
|
|
||||||
p = btle.Peripheral(adress,iface=args.interface)
|
|
||||||
val=b'\x01\x00'
|
|
||||||
p.writeCharacteristic(0x0038,val,True) #enable notifications of Temperature, Humidity and Battery voltage
|
|
||||||
p.writeCharacteristic(0x0046,b'\xf4\x01\x00',True)
|
|
||||||
p.withDelegate(MyDelegate("abc"))
|
|
||||||
return p
|
|
||||||
|
|
||||||
# Main loop --------
|
|
||||||
parser=argparse.ArgumentParser(allow_abbrev=False)
|
|
||||||
parser.add_argument("--device","-d", help="Set the device MAC-Address in format AA:BB:CC:DD:EE:FF",metavar='AA:BB:CC:DD:EE:FF')
|
|
||||||
parser.add_argument("--battery","-b", help="Get estimated battery level", metavar='', type=int, nargs='?', const=1)
|
|
||||||
parser.add_argument("--count","-c", help="Read/Receive N measurements and then exit script", metavar='N', type=int)
|
|
||||||
parser.add_argument("--interface","-i", help="Specifiy the interface number to use, e.g. 1 for hci1", metavar='N', type=int, default=0)
|
|
||||||
parser.add_argument("--unreachable-count","-urc", help="Exit after N unsuccessful connection tries", metavar='N', type=int, default=0)
|
|
||||||
|
|
||||||
|
|
||||||
rounding = parser.add_argument_group("Rounding and debouncing")
|
|
||||||
rounding.add_argument("--round","-r", help="Round temperature to one decimal place",action='store_true')
|
|
||||||
rounding.add_argument("--debounce","-deb", help="Enable this option to get more stable temperature values, requires -r option",action='store_true')
|
|
||||||
|
|
||||||
offsetgroup = parser.add_argument_group("Offset calibration mode")
|
|
||||||
offsetgroup.add_argument("--offset","-o", help="Enter an offset to the reported humidity value",type=int)
|
|
||||||
|
|
||||||
complexCalibrationGroup=parser.add_argument_group("2 Point Calibration")
|
|
||||||
complexCalibrationGroup.add_argument("--TwoPointCalibration","-2p", help="Use complex calibration mode. All arguments below are required",action='store_true')
|
|
||||||
complexCalibrationGroup.add_argument("--calpoint1","-p1", help="Enter the first calibration point",type=int)
|
|
||||||
complexCalibrationGroup.add_argument("--offset1","-o1", help="Enter the offset for the first calibration point",type=int)
|
|
||||||
complexCalibrationGroup.add_argument("--calpoint2","-p2", help="Enter the second calibration point",type=int)
|
|
||||||
complexCalibrationGroup.add_argument("--offset2","-o2", help="Enter the offset for the second calibration point",type=int)
|
|
||||||
|
|
||||||
callbackgroup = parser.add_argument_group("Callback related arguments")
|
|
||||||
callbackgroup.add_argument("--callback","-call", help="Pass the path to a program/script that will be called on each new measurement")
|
|
||||||
callbackgroup.add_argument("--name","-n", help="Give this sensor a name reported to the callback script")
|
|
||||||
callbackgroup.add_argument("--skipidentical","-skip", help="N consecutive identical measurements won't be reported to callbackfunction",metavar='N', type=int, default=0)
|
|
||||||
callbackgroup.add_argument("--influxdb","-infl", help="Optimize for writing data to influxdb,1 timestamp optimization, 2 integer optimization",metavar='N', type=int, default=0)
|
|
||||||
|
|
||||||
atcgroup = parser.add_argument_group("ATC mode related arguments")
|
|
||||||
atcgroup.add_argument("--atc","-a", help="Read the data of devices with custom ATC firmware flashed",action='store_true')
|
|
||||||
atcgroup.add_argument("--watchdogtimer","-wdt",metavar='X', type=int, help="Re-enable scanning after not receiving any BLE packet after X seconds")
|
|
||||||
atcgroup.add_argument("--devicelistfile","-df",help="Specify a device list file giving further details to devices")
|
|
||||||
atcgroup.add_argument("--onlydevicelist","-odl", help="Only read devices which are in the device list file",action='store_true')
|
|
||||||
atcgroup.add_argument("--rssi","-rs", help="Report RSSI via callback",action='store_true')
|
|
||||||
|
|
||||||
|
|
||||||
args=parser.parse_args()
|
|
||||||
if args.device:
|
|
||||||
if re.match("[0-9a-fA-F]{2}([:]?)[0-9a-fA-F]{2}(\\1[0-9a-fA-F]{2}){4}$",args.device):
|
|
||||||
adress=args.device
|
|
||||||
else:
|
|
||||||
print("Please specify device MAC-Address in format AA:BB:CC:DD:EE:FF")
|
|
||||||
os._exit(1)
|
|
||||||
elif not args.atc:
|
|
||||||
parser.print_help()
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
if args.TwoPointCalibration:
|
|
||||||
if(not(args.calpoint1 and args.offset1 and args.calpoint2 and args.offset2)):
|
|
||||||
print("In 2 Point calibration you have to enter 4 points")
|
|
||||||
os._exit(1)
|
|
||||||
elif(args.offset):
|
|
||||||
print("Offset calibration and 2 Point calibration can't be used together")
|
|
||||||
os._exit(1)
|
|
||||||
if not args.name:
|
|
||||||
args.name = args.device
|
|
||||||
|
|
||||||
if args.callback:
|
|
||||||
dataThread = threading.Thread(target=thread_SendingData)
|
|
||||||
dataThread.start()
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
|
|
||||||
if args.device:
|
|
||||||
|
|
||||||
p=btle.Peripheral()
|
|
||||||
cnt=0
|
|
||||||
|
|
||||||
|
|
||||||
connected=False
|
|
||||||
#logging.basicConfig(level=logging.DEBUG)
|
|
||||||
logging.basicConfig(level=logging.ERROR)
|
|
||||||
logging.debug("Debug: Starting script...")
|
|
||||||
pid=os.getpid()
|
|
||||||
bluepypid=None
|
|
||||||
unconnectedTime=None
|
|
||||||
connectionLostCounter=0
|
|
||||||
|
|
||||||
watchdogThread = threading.Thread(target=watchDog_Thread)
|
|
||||||
watchdogThread.start()
|
|
||||||
logging.debug("watchdogThread started")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
if not connected:
|
|
||||||
#Bluepy sometimes hangs and makes it even impossible to connect with gatttool as long it is running
|
|
||||||
#on every new connection a new bluepy-helper is called
|
|
||||||
#we now make sure that the old one is really terminated. Even if it hangs a simple kill signal was sufficient to terminate it
|
|
||||||
# if bluepypid is not None:
|
|
||||||
# os.system("kill " + bluepypid)
|
|
||||||
# print("Killed possibly remaining bluepy-helper")
|
|
||||||
# else:
|
|
||||||
# print("bluepy-helper couldn't be determined, killing not allowed")
|
|
||||||
|
|
||||||
print("Trying to connect to " + adress)
|
|
||||||
p=connect()
|
|
||||||
# logging.debug("Own PID: " + str(pid))
|
|
||||||
# pstree=os.popen("pstree -p " + str(pid)).read() #we want to kill only bluepy from our own process tree, because other python scripts have there own bluepy-helper process
|
|
||||||
# logging.debug("PSTree: " + pstree)
|
|
||||||
# try:
|
|
||||||
# bluepypid=re.findall(r'bluepy-helper\((.*)\)',pstree)[0] #Store the bluepypid, to kill it later
|
|
||||||
# except IndexError: #Should not happen since we're now connected
|
|
||||||
# logging.debug("Couldn't find pid of bluepy-helper")
|
|
||||||
connected=True
|
|
||||||
unconnectedTime=None
|
|
||||||
|
|
||||||
# if args.battery:
|
|
||||||
# if(cnt % args.battery == 0):
|
|
||||||
# print("Warning the battery option is deprecated, Aqara device always reports 99 % battery")
|
|
||||||
# batt=p.readCharacteristic(0x001b)
|
|
||||||
# batt=int.from_bytes(batt,byteorder="little")
|
|
||||||
# print("Battery-Level: " + str(batt))
|
|
||||||
# globalBatteryLevel = batt
|
|
||||||
|
|
||||||
|
|
||||||
if p.waitForNotifications(2000):
|
|
||||||
# handleNotification() was called
|
|
||||||
|
|
||||||
cnt += 1
|
|
||||||
if args.count is not None and cnt >= args.count:
|
|
||||||
print(str(args.count) + " measurements collected. Exiting in a moment.")
|
|
||||||
p.disconnect()
|
|
||||||
time.sleep(5)
|
|
||||||
#It seems that sometimes bluepy-helper remains and thus prevents a reconnection, so we try killing our own bluepy-helper
|
|
||||||
pstree=os.popen("pstree -p " + str(pid)).read() #we want to kill only bluepy from our own process tree, because other python scripts have there own bluepy-helper process
|
|
||||||
bluepypid=0
|
|
||||||
try:
|
|
||||||
bluepypid=re.findall(r'bluepy-helper\((.*)\)',pstree)[0] #Store the bluepypid, to kill it later
|
|
||||||
except IndexError: #Should normally occur because we're disconnected
|
|
||||||
logging.debug("Couldn't find pid of bluepy-helper")
|
|
||||||
if bluepypid != 0:
|
|
||||||
os.system("kill " + bluepypid)
|
|
||||||
logging.debug("Killed bluepy with pid: " + str(bluepypid))
|
|
||||||
os._exit(0)
|
|
||||||
print("")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
print("Connection lost")
|
|
||||||
connectionLostCounter +=1
|
|
||||||
if connected is True: #First connection abort after connected
|
|
||||||
unconnectedTime=int(time.time())
|
|
||||||
connected=False
|
|
||||||
if args.unreachable_count != 0 and connectionLostCounter >= args.unreachable_count:
|
|
||||||
print("Maximum numbers of unsuccessful connections reaches, exiting")
|
|
||||||
os._exit(0)
|
|
||||||
time.sleep(1)
|
|
||||||
logging.debug(e)
|
|
||||||
logging.debug(traceback.format_exc())
|
|
||||||
|
|
||||||
print ("Waiting...")
|
|
||||||
# Perhaps do something else here
|
|
||||||
|
|
||||||
elif args.atc:
|
|
||||||
print("Script started in ATC Mode")
|
|
||||||
print("----------------------------")
|
|
||||||
print("In this mode all devices within reach are read out, unless a namefile and --namefileonlydevices is specified.")
|
|
||||||
print("Also --name Argument is ignored, if you require names, please use --namefile.")
|
|
||||||
print("In this mode rounding and debouncing are not available, since ATC firmware sends out only one decimal place.")
|
|
||||||
print("ATC mode usually requires root rights. If you want to use it with normal user rights, \nplease execute \"sudo setcap cap_net_raw,cap_net_admin+eip $(eval readlink -f `which python3`)\"")
|
|
||||||
print("You have to redo this step if you upgrade your python version.")
|
|
||||||
print("----------------------------")
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import bluetooth._bluetooth as bluez
|
|
||||||
|
|
||||||
from bluetooth_utils import (toggle_device,
|
|
||||||
enable_le_scan, parse_le_advertising_events,
|
|
||||||
disable_le_scan, raw_packet_to_str)
|
|
||||||
mqttserver = "192.168.0.114"
|
|
||||||
mqttclient = mqtt.Client("MiTemperature2")
|
|
||||||
|
|
||||||
advCounter=dict()
|
|
||||||
sensors = dict()
|
|
||||||
if args.devicelistfile:
|
|
||||||
import configparser
|
|
||||||
if not os.path.exists(args.devicelistfile):
|
|
||||||
print ("Error specified device list file '",args.devicelistfile,"' not found")
|
|
||||||
os._exit(1)
|
|
||||||
sensors = configparser.ConfigParser()
|
|
||||||
sensors.read(args.devicelistfile)
|
|
||||||
|
|
||||||
if args.onlydevicelist and not args.devicelistfile:
|
|
||||||
print("Error: --onlydevicelist requires --devicelistfile <devicelistfile>")
|
|
||||||
os._exit(1)
|
|
||||||
|
|
||||||
dev_id = args.interface # the bluetooth device is hci0
|
|
||||||
toggle_device(dev_id, True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
sock = bluez.hci_open_dev(dev_id)
|
|
||||||
except:
|
|
||||||
print("Cannot open bluetooth device %i" % dev_id)
|
|
||||||
raise
|
|
||||||
|
|
||||||
enable_le_scan(sock, filter_duplicates=False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
prev_data = None
|
|
||||||
|
|
||||||
def le_advertise_packet_handler(mac, adv_type, data, rssi):
|
|
||||||
global lastBLEPaketReceived
|
|
||||||
if args.watchdogtimer:
|
|
||||||
lastBLEPaketReceived = time.time()
|
|
||||||
lastBLEPaketReceived = time.time()
|
|
||||||
#print("reveived BLE packet")
|
|
||||||
data_str = raw_packet_to_str(data)
|
|
||||||
ATCPaketMAC = data_str[10:22].upper()
|
|
||||||
macStr = mac.replace(":","").upper()
|
|
||||||
atcIdentifier = data_str[6:10].upper()
|
|
||||||
if(atcIdentifier == "1A18" and ATCPaketMAC == macStr) and not args.onlydevicelist or (atcIdentifier == "1A18" and mac in sensors): #only Data from ATC devices, double checked
|
|
||||||
advNumber = data_str[-2:]
|
|
||||||
if macStr in advCounter:
|
|
||||||
lastAdvNumber = advCounter[macStr]
|
|
||||||
else:
|
|
||||||
lastAdvNumber = None
|
|
||||||
if lastAdvNumber == None or lastAdvNumber != advNumber:
|
|
||||||
advCounter[macStr] = advNumber
|
|
||||||
print("BLE packet: %s %02x %s %d" % (mac, adv_type, data_str, rssi))
|
|
||||||
#print("AdvNumber: ", advNumber)
|
|
||||||
#temp = data_str[22:26].encode('utf-8')
|
|
||||||
#temperature = int.from_bytes(bytearray.fromhex(data_str[22:26]),byteorder='big') / 10.
|
|
||||||
global measurements
|
|
||||||
measurement = Measurement(0,0,0,0,0,0,0,0)
|
|
||||||
if args.influxdb == 1:
|
|
||||||
measurement.timestamp = int((time.time() // 10) * 10)
|
|
||||||
else:
|
|
||||||
measurement.timestamp = int(time.time())
|
|
||||||
|
|
||||||
|
|
||||||
#temperature = int(data_str[22:26],16) / 10.
|
|
||||||
temperature = int.from_bytes(bytearray.fromhex(data_str[22:26]),byteorder='big',signed=True) / 10.
|
|
||||||
print("Temperature: ", temperature)
|
|
||||||
humidity = int(data_str[26:28], 16)
|
|
||||||
print("Humidity: ", humidity)
|
|
||||||
batteryVoltage = int(data_str[30:34], 16) / 1000
|
|
||||||
print ("Battery voltage:", batteryVoltage,"V")
|
|
||||||
print ("RSSI:", rssi, "dBm")
|
|
||||||
|
|
||||||
if args.battery:
|
|
||||||
batteryPercent = int(data_str[28:30], 16)
|
|
||||||
print ("Battery:", batteryPercent,"%")
|
|
||||||
measurement.battery = batteryPercent
|
|
||||||
measurement.humidity = humidity
|
|
||||||
measurement.temperature = temperature
|
|
||||||
measurement.voltage = batteryVoltage
|
|
||||||
measurement.rssi = rssi
|
|
||||||
|
|
||||||
if mac in sensors:
|
|
||||||
try:
|
|
||||||
measurement.sensorname = sensors[mac]["sensorname"]
|
|
||||||
except:
|
|
||||||
measurement.sensorname = mac
|
|
||||||
if "offset1" in sensors[mac] and "offset2" in sensors[mac] and "calpoint1" in sensors[mac] and "calpoint2" in sensors[mac]:
|
|
||||||
measurement.humidity = calibrateHumidity2Points(humidity,int(sensors[mac]["offset1"]),int(sensors[mac]["offset2"]),int(sensors[mac]["calpoint1"]),int(sensors[mac]["calpoint2"]))
|
|
||||||
print ("Humidity calibrated (2 points calibration): ", measurement.humidity)
|
|
||||||
elif "humidityOffset" in sensors[mac]:
|
|
||||||
measurement.humidity = humidity + int(sensors[mac]["humidityOffset"])
|
|
||||||
print ("Humidity calibrated (offset calibration): ", measurement.humidity)
|
|
||||||
else:
|
|
||||||
measurement.sensorname = mac
|
|
||||||
|
|
||||||
print ("MQTT publishing")
|
|
||||||
mqttclient.connect(mqttserver)
|
|
||||||
mqttclient.publish(("MiTemperature2/%s/temp" % measurement.sensorname), measurement.temperature)
|
|
||||||
mqttclient.publish(("MiTemperature2/%s/humidity" % measurement.sensorname), measurement.humidity)
|
|
||||||
mqttclient.publish(("MiTemperature2/%s/batterylevel" % measurement.sensorname), measurement.battery)
|
|
||||||
print ("MQTT done")
|
|
||||||
|
|
||||||
|
|
||||||
if(args.callback):
|
|
||||||
measurements.append(measurement)
|
|
||||||
#print("Length:", len(measurements))
|
|
||||||
print("")
|
|
||||||
|
|
||||||
if args.watchdogtimer:
|
|
||||||
keepingLEScanRunningThread = threading.Thread(target=keepingLEScanRunning)
|
|
||||||
keepingLEScanRunningThread.start()
|
|
||||||
logging.debug("keepingLEScanRunningThread started")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Blocking call (the given handler will be called each time a new LE
|
|
||||||
# advertisement packet is detected)
|
|
||||||
parse_le_advertising_events(sock,
|
|
||||||
handler=le_advertise_packet_handler,
|
|
||||||
debug=False)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
disable_le_scan(sock)
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
# This file is from https://github.com/colin-guyon/py-bluetooth-utils
|
|
||||||
# published under MIT License
|
|
||||||
|
|
||||||
# MIT License
|
|
||||||
|
|
||||||
# Copyright (c) 2020 Colin GUYON
|
|
||||||
|
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
# of this software and associated documentation files (the "Software"), to deal
|
|
||||||
# in the Software without restriction, including without limitation the rights
|
|
||||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
# copies of the Software, and to permit persons to whom the Software is
|
|
||||||
# furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
# The above copyright notice and this permission notice shall be included in all
|
|
||||||
# copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
# SOFTWARE.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Module containing some bluetooth utility functions (linux only).
|
|
||||||
|
|
||||||
It either uses HCI commands using PyBluez, or does ioctl calls like it's
|
|
||||||
done in Bluez tools such as hciconfig.
|
|
||||||
|
|
||||||
Main functions:
|
|
||||||
- toggle_device : enable or disable a bluetooth device
|
|
||||||
- set_scan : set scan type on a device ("noscan", "iscan", "pscan", "piscan")
|
|
||||||
- enable/disable_le_scan : enable BLE scanning
|
|
||||||
- parse_le_advertising_events : parse and read BLE advertisements packets
|
|
||||||
- start/stop_le_advertising : advertise custom data using BLE
|
|
||||||
|
|
||||||
Bluez : http://www.bluez.org/
|
|
||||||
PyBluez : http://karulis.github.io/pybluez/
|
|
||||||
|
|
||||||
The module was in particular inspired from 'iBeacon-Scanner-'
|
|
||||||
https://github.com/switchdoclabs/iBeacon-Scanner-/blob/master/blescan.py
|
|
||||||
and sometimes directly from the Bluez sources.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import absolute_import
|
|
||||||
import sys
|
|
||||||
import struct
|
|
||||||
import fcntl
|
|
||||||
import array
|
|
||||||
import socket
|
|
||||||
from errno import EALREADY
|
|
||||||
|
|
||||||
# import PyBluez
|
|
||||||
import bluetooth._bluetooth as bluez
|
|
||||||
|
|
||||||
__all__ = ('toggle_device', 'set_scan',
|
|
||||||
'enable_le_scan', 'disable_le_scan', 'parse_le_advertising_events',
|
|
||||||
'start_le_advertising', 'stop_le_advertising',
|
|
||||||
'raw_packet_to_str')
|
|
||||||
|
|
||||||
LE_META_EVENT = 0x3E
|
|
||||||
LE_PUBLIC_ADDRESS = 0x00
|
|
||||||
LE_RANDOM_ADDRESS = 0x01
|
|
||||||
|
|
||||||
OGF_LE_CTL = 0x08
|
|
||||||
OCF_LE_SET_SCAN_PARAMETERS = 0x000B
|
|
||||||
OCF_LE_SET_SCAN_ENABLE = 0x000C
|
|
||||||
OCF_LE_CREATE_CONN = 0x000D
|
|
||||||
OCF_LE_SET_ADVERTISING_PARAMETERS = 0x0006
|
|
||||||
OCF_LE_SET_ADVERTISE_ENABLE = 0x000A
|
|
||||||
OCF_LE_SET_ADVERTISING_DATA = 0x0008
|
|
||||||
|
|
||||||
SCAN_TYPE_PASSIVE = 0x00
|
|
||||||
SCAN_FILTER_DUPLICATES = 0x01
|
|
||||||
SCAN_DISABLE = 0x00
|
|
||||||
SCAN_ENABLE = 0x01
|
|
||||||
|
|
||||||
# sub-events of LE_META_EVENT
|
|
||||||
EVT_LE_CONN_COMPLETE = 0x01
|
|
||||||
EVT_LE_ADVERTISING_REPORT = 0x02
|
|
||||||
EVT_LE_CONN_UPDATE_COMPLETE = 0x03
|
|
||||||
EVT_LE_READ_REMOTE_USED_FEATURES_COMPLETE = 0x04
|
|
||||||
|
|
||||||
# Advertisement event types
|
|
||||||
ADV_IND = 0x00
|
|
||||||
ADV_DIRECT_IND = 0x01
|
|
||||||
ADV_SCAN_IND = 0x02
|
|
||||||
ADV_NONCONN_IND = 0x03
|
|
||||||
ADV_SCAN_RSP = 0x04
|
|
||||||
|
|
||||||
# Allow Scan Request from Any, Connect Request from Any
|
|
||||||
FILTER_POLICY_NO_WHITELIST = 0x00
|
|
||||||
# Allow Scan Request from White List Only, Connect Request from Any
|
|
||||||
FILTER_POLICY_SCAN_WHITELIST = 0x01
|
|
||||||
# Allow Scan Request from Any, Connect Request from White List Only
|
|
||||||
FILTER_POLICY_CONN_WHITELIST = 0x02
|
|
||||||
# Allow Scan Request from White List Only, Connect Request from White List Only
|
|
||||||
FILTER_POLICY_SCAN_AND_CONN_WHITELIST = 0x03
|
|
||||||
|
|
||||||
|
|
||||||
def toggle_device(dev_id, enable):
|
|
||||||
"""
|
|
||||||
Power ON or OFF a bluetooth device.
|
|
||||||
|
|
||||||
:param dev_id: Device id.
|
|
||||||
:type dev_id: ``int``
|
|
||||||
:param enable: Whether to enable of disable the device.
|
|
||||||
:type enable: ``bool``
|
|
||||||
"""
|
|
||||||
hci_sock = socket.socket(socket.AF_BLUETOOTH,
|
|
||||||
socket.SOCK_RAW,
|
|
||||||
socket.BTPROTO_HCI)
|
|
||||||
print("Power %s bluetooth device %d" % ('ON' if enable else 'OFF', dev_id))
|
|
||||||
# di = struct.pack("HbBIBBIIIHHHH10I", dev_id, *((0,) * 22))
|
|
||||||
# fcntl.ioctl(hci_sock.fileno(), bluez.HCIGETDEVINFO, di)
|
|
||||||
req_str = struct.pack("H", dev_id)
|
|
||||||
request = array.array("b", req_str)
|
|
||||||
try:
|
|
||||||
fcntl.ioctl(hci_sock.fileno(),
|
|
||||||
bluez.HCIDEVUP if enable else bluez.HCIDEVDOWN,
|
|
||||||
request[0])
|
|
||||||
except IOError as e:
|
|
||||||
if e.errno == EALREADY:
|
|
||||||
print("Bluetooth device %d is already %s" % (
|
|
||||||
dev_id, 'enabled' if enable else 'disabled'))
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
hci_sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Types of bluetooth scan
|
|
||||||
SCAN_DISABLED = 0x00
|
|
||||||
SCAN_INQUIRY = 0x01
|
|
||||||
SCAN_PAGE = 0x02
|
|
||||||
|
|
||||||
|
|
||||||
def set_scan(dev_id, scan_type):
|
|
||||||
"""
|
|
||||||
Set scan type on a given bluetooth device.
|
|
||||||
|
|
||||||
:param dev_id: Device id.
|
|
||||||
:type dev_id: ``int``
|
|
||||||
:param scan_type: One of
|
|
||||||
``'noscan'``
|
|
||||||
``'iscan'``
|
|
||||||
``'pscan'``
|
|
||||||
``'piscan'``
|
|
||||||
:type scan_type: ``str``
|
|
||||||
"""
|
|
||||||
hci_sock = socket.socket(socket.AF_BLUETOOTH,
|
|
||||||
socket.SOCK_RAW,
|
|
||||||
socket.BTPROTO_HCI)
|
|
||||||
if scan_type == "noscan":
|
|
||||||
dev_opt = SCAN_DISABLED
|
|
||||||
elif scan_type == "iscan":
|
|
||||||
dev_opt = SCAN_INQUIRY
|
|
||||||
elif scan_type == "pscan":
|
|
||||||
dev_opt = SCAN_PAGE
|
|
||||||
elif scan_type == "piscan":
|
|
||||||
dev_opt = SCAN_PAGE | SCAN_INQUIRY
|
|
||||||
else:
|
|
||||||
raise ValueError("Unknown scan type %r" % scan_type)
|
|
||||||
|
|
||||||
req_str = struct.pack("HI", dev_id, dev_opt)
|
|
||||||
print("Set scan type %r to bluetooth device %d" % (scan_type, dev_id))
|
|
||||||
try:
|
|
||||||
fcntl.ioctl(hci_sock.fileno(), bluez.HCISETSCAN, req_str)
|
|
||||||
finally:
|
|
||||||
hci_sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
def raw_packet_to_str(pkt):
|
|
||||||
"""
|
|
||||||
Returns the string representation of a raw HCI packet.
|
|
||||||
"""
|
|
||||||
if sys.version_info > (3, 0):
|
|
||||||
return ''.join('%02x' % struct.unpack("B", bytes([x]))[0] for x in pkt)
|
|
||||||
else:
|
|
||||||
return ''.join('%02x' % struct.unpack("B", x)[0] for x in pkt)
|
|
||||||
|
|
||||||
|
|
||||||
def enable_le_scan(sock, interval=0x0800, window=0x0800,
|
|
||||||
filter_policy=FILTER_POLICY_NO_WHITELIST,
|
|
||||||
filter_duplicates=True):
|
|
||||||
"""
|
|
||||||
Enable LE passive scan (with filtering of duplicate packets enabled).
|
|
||||||
|
|
||||||
:param sock: A bluetooth HCI socket (retrieved using the
|
|
||||||
``hci_open_dev`` PyBluez function).
|
|
||||||
:param interval: Scan interval.
|
|
||||||
:param window: Scan window (must be less or equal than given interval).
|
|
||||||
:param filter_policy: One of
|
|
||||||
``FILTER_POLICY_NO_WHITELIST`` (default value)
|
|
||||||
``FILTER_POLICY_SCAN_WHITELIST``
|
|
||||||
``FILTER_POLICY_CONN_WHITELIST``
|
|
||||||
``FILTER_POLICY_SCAN_AND_CONN_WHITELIST``
|
|
||||||
|
|
||||||
.. note:: Scan interval and window are to multiply by 0.625 ms to
|
|
||||||
get the real time duration.
|
|
||||||
"""
|
|
||||||
print("Enable LE scan")
|
|
||||||
own_bdaddr_type = LE_PUBLIC_ADDRESS # does not work with LE_RANDOM_ADDRESS
|
|
||||||
cmd_pkt = struct.pack("<BHHBB", SCAN_TYPE_PASSIVE, interval, window,
|
|
||||||
own_bdaddr_type, filter_policy)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_PARAMETERS, cmd_pkt)
|
|
||||||
print("scan params: interval=%.3fms window=%.3fms own_bdaddr=%s "
|
|
||||||
"whitelist=%s" %
|
|
||||||
(interval * 0.625, window * 0.625,
|
|
||||||
'public' if own_bdaddr_type == LE_PUBLIC_ADDRESS else 'random',
|
|
||||||
'yes' if filter_policy in (FILTER_POLICY_SCAN_WHITELIST,
|
|
||||||
FILTER_POLICY_SCAN_AND_CONN_WHITELIST)
|
|
||||||
else 'no'))
|
|
||||||
cmd_pkt = struct.pack("<BB", SCAN_ENABLE, SCAN_FILTER_DUPLICATES if filter_duplicates else 0x00)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, cmd_pkt)
|
|
||||||
|
|
||||||
|
|
||||||
def disable_le_scan(sock):
|
|
||||||
"""
|
|
||||||
Disable LE scan.
|
|
||||||
|
|
||||||
:param sock: A bluetooth HCI socket (retrieved using the
|
|
||||||
``hci_open_dev`` PyBluez function).
|
|
||||||
"""
|
|
||||||
print("Disable LE scan")
|
|
||||||
cmd_pkt = struct.pack("<BB", SCAN_DISABLE, 0x00)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, cmd_pkt)
|
|
||||||
|
|
||||||
|
|
||||||
def start_le_advertising(sock, min_interval=1000, max_interval=1000,
|
|
||||||
adv_type=ADV_NONCONN_IND, data=()):
|
|
||||||
"""
|
|
||||||
Start LE advertising.
|
|
||||||
|
|
||||||
:param sock: A bluetooth HCI socket (retrieved using the
|
|
||||||
``hci_open_dev`` PyBluez function).
|
|
||||||
:param min_interval: Minimum advertising interval.
|
|
||||||
:param max_interval: Maximum advertising interval.
|
|
||||||
:param adv_type: Advertisement type (``ADV_NONCONN_IND`` by default).
|
|
||||||
:param data: The advertisement data (maximum of 31 bytes).
|
|
||||||
:type data: iterable
|
|
||||||
"""
|
|
||||||
own_bdaddr_type = 0
|
|
||||||
direct_bdaddr_type = 0
|
|
||||||
direct_bdaddr = (0,) * 6
|
|
||||||
chan_map = 0x07 # All channels: 37, 38, 39
|
|
||||||
filter = 0
|
|
||||||
|
|
||||||
struct_params = [min_interval, max_interval, adv_type, own_bdaddr_type,
|
|
||||||
direct_bdaddr_type]
|
|
||||||
struct_params.extend(direct_bdaddr)
|
|
||||||
struct_params.extend((chan_map, filter))
|
|
||||||
|
|
||||||
cmd_pkt = struct.pack("<HHBBB6BBB", *struct_params)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISING_PARAMETERS,
|
|
||||||
cmd_pkt)
|
|
||||||
|
|
||||||
cmd_pkt = struct.pack("<B", 0x01)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISE_ENABLE, cmd_pkt)
|
|
||||||
|
|
||||||
data_length = len(data)
|
|
||||||
if data_length > 31:
|
|
||||||
raise ValueError("data is too long (%d but max is 31 bytes)",
|
|
||||||
data_length)
|
|
||||||
cmd_pkt = struct.pack("<B%dB" % data_length, data_length, *data)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISING_DATA, cmd_pkt)
|
|
||||||
print("Advertising started data_length=%d data=%r" % (data_length, data))
|
|
||||||
|
|
||||||
|
|
||||||
def stop_le_advertising(sock):
|
|
||||||
"""
|
|
||||||
Stop LE advertising.
|
|
||||||
|
|
||||||
:param sock: A bluetooth HCI socket (retrieved using the
|
|
||||||
``hci_open_dev`` PyBluez function).
|
|
||||||
"""
|
|
||||||
cmd_pkt = struct.pack("<B", 0x00)
|
|
||||||
bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_ADVERTISE_ENABLE, cmd_pkt)
|
|
||||||
print("Advertising stopped")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_le_advertising_events(sock, mac_addr=None, packet_length=None,
|
|
||||||
handler=None, debug=False):
|
|
||||||
"""
|
|
||||||
Parse and report LE advertisements.
|
|
||||||
|
|
||||||
This is a blocking call, an infinite loop is started and the
|
|
||||||
given handler will be called each time a new LE advertisement packet
|
|
||||||
is detected and corresponds to the given filters.
|
|
||||||
|
|
||||||
.. note:: The :func:`.start_le_advertising` function must be
|
|
||||||
called before calling this function.
|
|
||||||
|
|
||||||
:param sock: A bluetooth HCI socket (retrieved using the
|
|
||||||
``hci_open_dev`` PyBluez function).
|
|
||||||
:param mac_addr: list of filtered mac address representations
|
|
||||||
(uppercase, with ':' separators).
|
|
||||||
If not specified, the LE advertisement of any device will be reported.
|
|
||||||
Example: mac_addr=('00:2A:5F:FF:25:11', 'DA:FF:12:33:66:12')
|
|
||||||
:type mac_addr: ``list`` of ``string``
|
|
||||||
:param packet_length: Filter a specific length of LE advertisement packet.
|
|
||||||
:type packet_length: ``int``
|
|
||||||
:param handler: Handler that will be called each time a LE advertisement
|
|
||||||
packet is available (in accordance with the ``mac_addr``
|
|
||||||
and ``packet_length`` filters).
|
|
||||||
:type handler: ``callable`` taking 4 parameters:
|
|
||||||
mac (``str``), adv_type (``int``), data (``bytes``) and rssi (``int``)
|
|
||||||
:param debug: Enable debug prints.
|
|
||||||
:type debug: ``bool``
|
|
||||||
"""
|
|
||||||
if not debug and handler is None:
|
|
||||||
raise ValueError("You must either enable debug or give a handler !")
|
|
||||||
|
|
||||||
old_filter = sock.getsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, 14)
|
|
||||||
|
|
||||||
flt = bluez.hci_filter_new()
|
|
||||||
bluez.hci_filter_set_ptype(flt, bluez.HCI_EVENT_PKT)
|
|
||||||
# bluez.hci_filter_all_events(flt)
|
|
||||||
bluez.hci_filter_set_event(flt, LE_META_EVENT)
|
|
||||||
sock.setsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, flt)
|
|
||||||
|
|
||||||
print("socket filter set to ptype=HCI_EVENT_PKT event=LE_META_EVENT")
|
|
||||||
print("Listening ...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
pkt = full_pkt = sock.recv(255)
|
|
||||||
ptype, event, plen = struct.unpack("BBB", pkt[:3])
|
|
||||||
|
|
||||||
if event != LE_META_EVENT:
|
|
||||||
# Should never occur because we filtered with this type of event
|
|
||||||
print("Not a LE_META_EVENT !")
|
|
||||||
continue
|
|
||||||
|
|
||||||
sub_event, = struct.unpack("B", pkt[3:4])
|
|
||||||
if sub_event != EVT_LE_ADVERTISING_REPORT:
|
|
||||||
if debug:
|
|
||||||
print("Not a EVT_LE_ADVERTISING_REPORT !")
|
|
||||||
continue
|
|
||||||
|
|
||||||
pkt = pkt[4:]
|
|
||||||
adv_type = struct.unpack("b", pkt[1:2])[0]
|
|
||||||
mac_addr_str = bluez.ba2str(pkt[3:9])
|
|
||||||
|
|
||||||
if packet_length and plen != packet_length:
|
|
||||||
# ignore this packet
|
|
||||||
if debug:
|
|
||||||
print("packet with non-matching length: mac=%s adv_type=%02x plen=%s" %
|
|
||||||
(mac_addr_str, adv_type, plen))
|
|
||||||
print(raw_packet_to_str(pkt))
|
|
||||||
continue
|
|
||||||
|
|
||||||
data = pkt[9:-1]
|
|
||||||
|
|
||||||
rssi = struct.unpack("b", full_pkt[len(full_pkt)-1:len(full_pkt)])[0]
|
|
||||||
|
|
||||||
if mac_addr and mac_addr_str not in mac_addr:
|
|
||||||
if debug:
|
|
||||||
print("packet with non-matching mac %s adv_type=%02x data=%s RSSI=%s" %
|
|
||||||
(mac_addr_str, adv_type, raw_packet_to_str(data), rssi))
|
|
||||||
continue
|
|
||||||
|
|
||||||
if debug:
|
|
||||||
print("LE advertisement: mac=%s adv_type=%02x data=%s RSSI=%d" %
|
|
||||||
(mac_addr_str, adv_type, raw_packet_to_str(data), rssi))
|
|
||||||
|
|
||||||
if handler is not None:
|
|
||||||
try:
|
|
||||||
handler(mac_addr_str, adv_type, data, rssi)
|
|
||||||
except Exception as e:
|
|
||||||
print('Exception when calling handler with a BLE advertising event: %r' % (e,))
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nRestore previous socket filter")
|
|
||||||
sock.setsockopt(bluez.SOL_HCI, bluez.HCI_FILTER, old_filter)
|
|
||||||
raise
|
|
||||||
|
|
||||||
"""
|
|
||||||
def hci_le_add_white_list(int dd, const bdaddr_t *bdaddr, uint8_t type, int to)
|
|
||||||
{
|
|
||||||
struct hci_request {
|
|
||||||
uint16_t ogf;
|
|
||||||
uint16_t ocf;
|
|
||||||
int event;
|
|
||||||
void *cparam;
|
|
||||||
int clen;
|
|
||||||
void *rparam;
|
|
||||||
int rlen;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct hci_request rq;
|
|
||||||
le_add_device_to_white_list_cp cp;
|
|
||||||
uint8_t status;
|
|
||||||
|
|
||||||
memset(&cp, 0, sizeof(cp));
|
|
||||||
cp.bdaddr_type = type;
|
|
||||||
bacpy(&cp.bdaddr, bdaddr);
|
|
||||||
|
|
||||||
memset(&rq, 0, sizeof(rq));
|
|
||||||
rq.ogf = OGF_LE_CTL;
|
|
||||||
rq.ocf = OCF_LE_ADD_DEVICE_TO_WHITE_LIST;
|
|
||||||
rq.cparam = &cp;
|
|
||||||
rq.clen = LE_ADD_DEVICE_TO_WHITE_LIST_CP_SIZE;
|
|
||||||
rq.rparam = &status;
|
|
||||||
rq.rlen = 1;
|
|
||||||
|
|
||||||
if (hci_send_req(dd, &rq, to) < 0)
|
|
||||||
return -1;
|
|
||||||
|
|
||||||
if (status) {
|
|
||||||
errno = EIO;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}"""
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
#This script is provided by Chiunownow https://github.com/Chiunownow
|
|
||||||
#Thank you very much for providing this script
|
|
||||||
#This script is
|
|
||||||
|
|
||||||
#use e.g with that script: MySensor.sh
|
|
||||||
#!/bin/bash
|
|
||||||
#DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
|
||||||
#$DIR/LYWSD03MMC.py -d <device> -b 1000 -r --debounce --skipidentical 50 --name MySensor --callback sendToMQTT
|
|
||||||
|
|
||||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/temp" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$3"
|
|
||||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/humidity" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$4"
|
|
||||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/batteryvoltage" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$5"
|
|
||||||
#mosquitto_pub -h mqtt.host -t "MiTemperature2/$2/batterylevel" -u mqtt.username -P mqtt.passwd -i "mibridge" -m "$6"
|
|
||||||
|
|
||||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/temp" -u hasse -P casablanca -i "mibridge" -m "$3"
|
|
||||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/humidity" -u hasse -P casablanca -i "mibridge" -m "$4"
|
|
||||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/batteryvoltage" -u hasse -P casablanca -i "mibridge" -m "$5"
|
|
||||||
mosquitto_pub -h 192.168.0.114 -t "MiTemperature2/$2/batterylevel" -u hasse -P casablanca -i "mibridge" -m "$6"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[A4:C1:38:98:7B:B6]
|
|
||||||
sensorname=mi_temp_1
|
|
||||||
|
|
||||||
[A4:C1:38:29:03:0D]
|
|
||||||
sensorname=mi_temp_2
|
|
||||||
|
|
||||||
[A4:C1:38:62:CA:83]
|
|
||||||
sensorname=mi_temp_3
|
|
||||||
|
|
||||||
[A4:C1:38:D5:EA:63]
|
|
||||||
sensorname=mi_temp_4
|
|
||||||
|
|
||||||
[A4:C1:38:7C:9C:63]
|
|
||||||
sensorname=mi_temp_5
|
|
||||||
|
|
||||||
[A4:C1:38:68:2C:DA]
|
|
||||||
sensorname=mi_temp_6
|
|
||||||
|
|
||||||
[A4:C1:38:AD:74:2B]
|
|
||||||
sensorname=mi_temp_7
|
|
||||||
|
|
||||||
[A4:C1:38:46:9F:D1]
|
|
||||||
sensorname=mi_temp_8
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# körs vid boot
|
|
||||||
|
|
||||||
tmux new-session -d -s sensorer '/home/pi/sensorpajen/LYWSD03MMC.py -a -wdt 5 --devicelistfile sensorer.ini --callback yes --battery'
|
|
||||||
tmux detach -s sensorer
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
tmux start-server
|
|
||||||
|
|
||||||
tmux new-session -d -s sensorer -n sensorer -d 'cd /home/pi/sensorpajen; ./LYWSD03MMC.py -a -wdt 5 --devicelistfile sensorer.ini --callback yes --battery'
|
|
||||||
tmux split-window -t sensorer:0 'cd /home/pi/pirate_audio; ./loop.sh'
|
|
||||||
tmux split-window -t sensorer:0 'cd /home/pi/pirate_audio; ./buttons.py'
|
|
||||||
|
|
||||||
tmux select-layout -t sensorer:0 tiled
|
|
||||||
|
|
||||||
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sensorpajen"
|
name = "sensorpajen"
|
||||||
version = "2.0.0-dev"
|
version = "3.0.0"
|
||||||
description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC"
|
description = "Bluetooth temperature sensor monitor for Xiaomi Mijia LYWSD03MMC"
|
||||||
readme = "README.md"
|
readme = "readme.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [
|
authors = [
|
||||||
@@ -14,7 +14,7 @@ authors = [
|
|||||||
]
|
]
|
||||||
keywords = ["bluetooth", "temperature", "sensor", "mqtt", "raspberry-pi"]
|
keywords = ["bluetooth", "temperature", "sensor", "mqtt", "raspberry-pi"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
@@ -26,8 +26,10 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"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]
|
||||||
@@ -45,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"]
|
||||||
|
|||||||
190
readme.md
190
readme.md
@@ -22,56 +22,91 @@ Raspberry Pi service that monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
See [SETUP_ON_PI.md](SETUP_ON_PI.md) for complete installation instructions.
|
### System Installation (Debian Package - Recommended for Raspberry Pi)
|
||||||
|
|
||||||
### Quick Start
|
The easiest way to install on Raspberry Pi OS is using the pre-built Debian package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Download the latest release
|
||||||
git clone <repo-url> ~/sensorpajen
|
wget https://gitea.wahlberg.se/api/v1/repos/fredrik/sensorpajen/releases/download/v3.0.0/sensorpajen_3.0.0_all.deb
|
||||||
cd ~/sensorpajen
|
|
||||||
|
|
||||||
# Create and activate virtual environment
|
# Install
|
||||||
python3 -m venv .venv
|
sudo dpkg -i sensorpajen_3.0.0_all.deb
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
# Configure
|
# Configure
|
||||||
cp config/sensorpajen.env.example config/sensorpajen.env
|
sudo nano /etc/sensorpajen/sensorpajen.env # Edit MQTT settings
|
||||||
cp config/sensors.json.example config/sensors.json
|
sudo systemctl restart sensorpajen
|
||||||
nano config/sensorpajen.env # Edit MQTT settings
|
|
||||||
nano config/sensors.json # Edit sensor MAC addresses
|
|
||||||
|
|
||||||
# Set Bluetooth capabilities
|
# View logs
|
||||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f .venv/bin/python3)
|
sudo journalctl -u sensorpajen -f
|
||||||
|
|
||||||
# Install systemd service
|
|
||||||
mkdir -p ~/.config/systemd/user/
|
|
||||||
cp systemd/sensorpajen.service ~/.config/systemd/user/
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
sudo loginctl enable-linger $USER
|
|
||||||
|
|
||||||
# Start service
|
|
||||||
systemctl --user enable sensorpajen
|
|
||||||
systemctl --user start sensorpajen
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
systemctl --user status sensorpajen
|
|
||||||
journalctl --user -u sensorpajen -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The system package installs:
|
||||||
|
- Application in `/opt/sensorpajen/`
|
||||||
|
- Configuration in `/etc/sensorpajen/`
|
||||||
|
- Runtime state in `/var/lib/sensorpajen/`
|
||||||
|
- Systemd service (runs automatically)
|
||||||
|
|
||||||
|
### Development Installation
|
||||||
|
|
||||||
|
See [INSTALL.md](INSTALL.md) for complete development setup.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
### Quick Setup
|
||||||
|
|
||||||
|
After installation, configure your MQTT broker and sensors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit MQTT settings
|
||||||
|
sudo nano /etc/sensorpajen/sensorpajen.env
|
||||||
|
|
||||||
|
# Restart service to apply changes
|
||||||
|
sudo systemctl restart sensorpajen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensor Management (TUI)
|
||||||
|
|
||||||
|
The service automatically discovers nearby Bluetooth sensors. You can manage them using the built-in Text UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the management TUI
|
||||||
|
sudo sensorpajen-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI allows you to:
|
||||||
|
- **Discovery**: View newly discovered sensors and **Approve** (add to monitoring) or **Ignore** them.
|
||||||
|
- **Configured**: View currently monitored sensors, **Edit** their names, or **Remove** them.
|
||||||
|
- **Ignored**: View ignored sensors and **Unignore** them if you change your mind.
|
||||||
|
|
||||||
|
**Keybindings:**
|
||||||
|
- `a`: Approve selected sensor
|
||||||
|
- `i`: Ignore selected sensor
|
||||||
|
- `e`: Edit sensor name and comment
|
||||||
|
- `v`: View details (MAC/name/comment)
|
||||||
|
- `u`: Unignore sensor
|
||||||
|
- `Delete`: Remove sensor from monitoring
|
||||||
|
- `r`: Refresh data
|
||||||
|
- `q`: Quit
|
||||||
|
|
||||||
|
When you approve a sensor, it's added to your configuration and the service automatically starts monitoring it.
|
||||||
|
|
||||||
|
### Legacy CLI Approval (Deprecated)
|
||||||
|
|
||||||
|
The recommended workflow is the TUI (`sensorpajen-tui`). A legacy CLI tool still exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sensorpajen-approve-sensors
|
||||||
|
```
|
||||||
|
|
||||||
### MQTT Settings
|
### MQTT Settings
|
||||||
|
|
||||||
Edit `config/sensorpajen.env`:
|
Edit `/etc/sensorpajen/sensorpajen.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
MQTT_HOST=192.168.1.10
|
MQTT_HOST=192.168.1.10
|
||||||
MQTT_PORT=1883
|
MQTT_PORT=1883
|
||||||
MQTT_USERNAME=username
|
MQTT_USER=username
|
||||||
MQTT_PASSWORD=password
|
MQTT_PASSWORD=password
|
||||||
MQTT_CLIENT_ID=sensorpajen
|
MQTT_CLIENT_ID=sensorpajen
|
||||||
MQTT_TOPIC_PREFIX=MiTemperature2
|
MQTT_TOPIC_PREFIX=MiTemperature2
|
||||||
@@ -79,10 +114,11 @@ MQTT_TOPIC_PREFIX=MiTemperature2
|
|||||||
|
|
||||||
### Sensors
|
### Sensors
|
||||||
|
|
||||||
Edit `config/sensors.json`:
|
Sensors are automatically managed via the approval workflow. You can also manually edit `/etc/sensorpajen/sensors.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
{
|
||||||
|
"sensors": [
|
||||||
{
|
{
|
||||||
"mac": "A4:C1:38:12:34:56",
|
"mac": "A4:C1:38:12:34:56",
|
||||||
"name": "Living Room"
|
"name": "Living Room"
|
||||||
@@ -92,29 +128,39 @@ Edit `config/sensors.json`:
|
|||||||
"name": "Bedroom"
|
"name": "Bedroom"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Service Management
|
## Service Management
|
||||||
|
|
||||||
See [systemd/README.md](systemd/README.md) for detailed service management instructions.
|
### System Installation (Debian Package)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start/stop service
|
# Start/stop service
|
||||||
systemctl --user start sensorpajen
|
sudo systemctl start sensorpajen
|
||||||
systemctl --user stop sensorpajen
|
sudo systemctl stop sensorpajen
|
||||||
|
|
||||||
# Enable/disable autostart
|
# Enable/disable autostart
|
||||||
systemctl --user enable sensorpajen
|
sudo systemctl enable sensorpajen
|
||||||
systemctl --user disable sensorpajen
|
sudo systemctl disable sensorpajen
|
||||||
|
|
||||||
# View status
|
# View status
|
||||||
systemctl --user status sensorpajen
|
sudo systemctl status sensorpajen
|
||||||
|
|
||||||
# View logs
|
# View logs (live)
|
||||||
journalctl --user -u sensorpajen -f
|
sudo journalctl -u sensorpajen -f
|
||||||
journalctl --user -u sensorpajen -n 100
|
|
||||||
|
# View last 50 log lines
|
||||||
|
sudo journalctl -u sensorpajen -n 50
|
||||||
|
|
||||||
|
# Uninstall
|
||||||
|
sudo dpkg -r sensorpajen
|
||||||
|
# Note: Configuration is preserved in /etc/sensorpajen/
|
||||||
|
# To remove config: sudo rm -rf /etc/sensorpajen/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Development Installation
|
||||||
|
|
||||||
## Flashing New Thermometers
|
## Flashing New Thermometers
|
||||||
|
|
||||||
**Important**: Flash only one thermometer at a time!
|
**Important**: Flash only one thermometer at a time!
|
||||||
@@ -158,57 +204,39 @@ sensorpajen/
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Permission Denied Errors
|
### System Installation (Debian Package)
|
||||||
|
|
||||||
If you see `PermissionError: [Errno 1] Operation not permitted`:
|
|
||||||
|
|
||||||
|
**Service won't start:**
|
||||||
```bash
|
```bash
|
||||||
# Verify capabilities are set
|
# Check what's wrong
|
||||||
getcap $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
sudo journalctl -u sensorpajen -n 50
|
||||||
|
|
||||||
# Should show: cap_net_admin,cap_net_raw+eip
|
# Check configuration is valid
|
||||||
# If not, set them:
|
sudo cat /etc/sensorpajen/sensorpajen.env
|
||||||
sudo setcap 'cap_net_raw,cap_net_admin+eip' $(readlink -f ~/sensorpajen/.venv/bin/python3)
|
|
||||||
|
|
||||||
# Restart service
|
# Manually test the application
|
||||||
systemctl --user restart sensorpajen
|
sudo /opt/sensorpajen/venv/bin/python -m sensorpajen.main
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Won't Start
|
**MQTT connection issues:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check service status
|
# Verify MQTT settings in the log
|
||||||
systemctl --user status sensorpajen
|
sudo journalctl -u sensorpajen | grep MQTT
|
||||||
|
|
||||||
# View logs
|
# Test MQTT connection manually
|
||||||
journalctl --user -u sensorpajen -n 50
|
mosquitto_sub -h <MQTT_HOST> -u <USER> -P <PASSWORD> -t "MiTemperature2/#" -v
|
||||||
|
|
||||||
# Common issues:
|
|
||||||
# - Missing config files (check config/ directory)
|
|
||||||
# - Wrong MQTT credentials (check config/sensorpajen.env)
|
|
||||||
# - Bluetooth not enabled (sudo systemctl start bluetooth)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### MQTT Connection Issues
|
**Sensor not found:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test MQTT connection
|
# Run the TUI to view/approve newly discovered sensors
|
||||||
mosquitto_sub -h <MQTT_HOST> -u <USERNAME> -P <PASSWORD> -t "MiTemperature2/#" -v
|
sudo sensorpajen-tui
|
||||||
|
|
||||||
# Check logs for connection errors
|
# Check recent logs
|
||||||
journalctl --user -u sensorpajen | grep -i mqtt
|
sudo journalctl -u sensorpajen -n 100
|
||||||
```
|
```
|
||||||
|
|
||||||
### No Sensor Data
|
### Development Installation
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check if sensors are visible
|
|
||||||
sudo hcitool lescan
|
|
||||||
|
|
||||||
# Verify sensor MAC addresses in config/sensors.json
|
|
||||||
# Make sure sensors have ATC firmware flashed
|
|
||||||
# Check battery level (low battery can cause connection issues)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
pybluez
|
||||||
bluepy
|
bluepy
|
||||||
paho-mqtt
|
paho-mqtt
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Wrapper script for approve-sensors that sets minimal required env vars
|
# Wrapper script for approve-sensors that works in both dev and system mode
|
||||||
|
|
||||||
# Get script directory
|
# Detect installation type
|
||||||
|
if [ -d "/opt/sensorpajen" ]; then
|
||||||
|
# System installation
|
||||||
|
PROJECT_ROOT="/opt/sensorpajen"
|
||||||
|
VENV_PATH="/opt/sensorpajen/venv"
|
||||||
|
|
||||||
|
# Load config from system location
|
||||||
|
if [ -f "/etc/sensorpajen/sensorpajen.env" ]; then
|
||||||
|
set -a
|
||||||
|
source /etc/sensorpajen/sensorpajen.env
|
||||||
|
set +a
|
||||||
|
else
|
||||||
|
echo "Warning: /etc/sensorpajen/sensorpajen.env not found"
|
||||||
|
# Set minimal defaults
|
||||||
|
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
||||||
|
export MQTT_PORT="${MQTT_PORT:-1883}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Development installation
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||||
|
VENV_PATH="$PROJECT_ROOT/.venv"
|
||||||
|
|
||||||
# Set minimal required environment variables
|
# Set minimal required environment variables
|
||||||
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
export MQTT_HOST="${MQTT_HOST:-localhost}"
|
||||||
@@ -15,9 +34,15 @@ if [ -f "$PROJECT_ROOT/config/sensorpajen.env" ]; then
|
|||||||
source "$PROJECT_ROOT/config/sensorpajen.env"
|
source "$PROJECT_ROOT/config/sensorpajen.env"
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Activate virtual environment
|
# Activate virtual environment
|
||||||
source "$PROJECT_ROOT/.venv/bin/activate"
|
if [ -f "$VENV_PATH/bin/activate" ]; then
|
||||||
|
source "$VENV_PATH/bin/activate"
|
||||||
|
else
|
||||||
|
echo "Error: Virtual environment not found at $VENV_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Run the approve-sensors command
|
# Run the approve-sensors command
|
||||||
python -m sensorpajen.approve_sensors "$@"
|
python -m sensorpajen.approve_sensors "$@"
|
||||||
|
|||||||
206
scripts/dev-remote.sh
Executable file
206
scripts/dev-remote.sh
Executable file
@@ -0,0 +1,206 @@
|
|||||||
|
#!/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' \
|
||||||
|
--exclude '*.db' --exclude '*.db-*' --exclude '*.sqlite' --exclude '*.sqlite-*' \
|
||||||
|
"$PROJECT_ROOT/src" "$PROJECT_ROOT/scripts" "$PROJECT_ROOT/pyproject.toml" "$PROJECT_ROOT/config" \
|
||||||
|
"$REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# and set explicit dev paths
|
||||||
|
if [ -f config/sensorpajen.env ]; then
|
||||||
|
echo 'Sanitizing and setting dev paths in config/sensorpajen.env...'
|
||||||
|
sudo sed -i '/^SENSOR_CONFIG_FILE/d' config/sensorpajen.env
|
||||||
|
sudo sed -i '/^DATABASE_FILE/d' config/sensorpajen.env
|
||||||
|
sudo sed -i '/^DISCOVERED_SENSORS_FILE/d' config/sensorpajen.env
|
||||||
|
|
||||||
|
# Add dev paths explicitly (use absolute paths since we're in ssh context)
|
||||||
|
echo "SENSOR_CONFIG_FILE=/home/$REMOTE_USER/sensorpajen-dev/config/sensors.json" | sudo tee -a config/sensorpajen.env > /dev/null
|
||||||
|
echo "DATABASE_FILE=/home/$REMOTE_USER/sensorpajen-dev/config/sensorpajen.db" | sudo tee -a config/sensorpajen.env > /dev/null
|
||||||
|
echo "DISCOVERED_SENSORS_FILE=/home/$REMOTE_USER/sensorpajen-dev/config/discovered_sensors.json" | sudo tee -a config/sensorpajen.env > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Examples (if real config missing)
|
||||||
|
# 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
|
||||||
|
export TUI_LOG_FILE=dev_backend.log
|
||||||
|
# Run TUI
|
||||||
|
python3 -m sensorpajen.tui.app
|
||||||
|
"
|
||||||
|
|
||||||
|
# Cleanup happens via trap
|
||||||
184
scripts/verify-deb.sh
Executable file
184
scripts/verify-deb.sh
Executable file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Automated verification script for Debian package
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Sensorpajen Debian Package Verification"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
echo -n "Checking for dpkg-deb... "
|
||||||
|
if command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}MISSING${NC}"
|
||||||
|
echo "Install with: sudo apt install dpkg-dev"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "Checking for lintian... "
|
||||||
|
if command -v lintian >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}MISSING${NC}"
|
||||||
|
echo "Install with: sudo apt install lintian"
|
||||||
|
echo "Continuing without lintian checks..."
|
||||||
|
SKIP_LINTIAN=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "Checking for debhelper... "
|
||||||
|
if dpkg -l debhelper >/dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}MISSING${NC}"
|
||||||
|
echo "Install with: sudo apt install debhelper"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get project root
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Check required files exist
|
||||||
|
echo "Checking required files..."
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
"debian/control"
|
||||||
|
"debian/rules"
|
||||||
|
"debian/install"
|
||||||
|
"debian/changelog"
|
||||||
|
"debian/postinst"
|
||||||
|
"debian/prerm"
|
||||||
|
"debian/postrm"
|
||||||
|
"debian/sensorpajen.service"
|
||||||
|
"src/sensorpajen/main.py"
|
||||||
|
"pyproject.toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional files (debian/compat is now optional - use Build-Depends instead)
|
||||||
|
OPTIONAL_FILES=(
|
||||||
|
"debian/compat"
|
||||||
|
)
|
||||||
|
|
||||||
|
ALL_FILES_OK=1
|
||||||
|
for file in "${REQUIRED_FILES[@]}"; do
|
||||||
|
echo -n " $file... "
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}MISSING${NC}"
|
||||||
|
ALL_FILES_OK=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check optional files
|
||||||
|
for file in "${OPTIONAL_FILES[@]}"; do
|
||||||
|
echo -n " $file... "
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
echo -e "${GREEN}OK${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}OPTIONAL${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $ALL_FILES_OK -eq 0 ]; then
|
||||||
|
echo -e "${RED}Some required files are missing!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Extract version from pyproject.toml
|
||||||
|
VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||||
|
echo "Package version: $VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clean previous builds
|
||||||
|
echo "Cleaning previous builds..."
|
||||||
|
rm -f ../*.deb ../*.build ../*.buildinfo ../*.changes
|
||||||
|
rm -rf debian/.debhelper debian/sensorpajen debian/files
|
||||||
|
|
||||||
|
# Build the package with gzip compression (for compatibility)
|
||||||
|
echo "Building Debian package..."
|
||||||
|
echo "======================================================================"
|
||||||
|
dpkg-buildpackage -us -uc -b -Zgzip
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Build failed!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Build successful!${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Find the built package
|
||||||
|
DEB_FILE=$(ls -t ../*.deb 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$DEB_FILE" ]; then
|
||||||
|
echo -e "${RED}No .deb file found!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Package: $DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show package contents
|
||||||
|
echo "Package contents:"
|
||||||
|
echo "======================================================================"
|
||||||
|
dpkg-deb -c "$DEB_FILE" | sed -n '1,20p'
|
||||||
|
TOTAL_FILES=$(dpkg-deb -c "$DEB_FILE" | wc -l)
|
||||||
|
if [ $TOTAL_FILES -gt 20 ]; then
|
||||||
|
echo "... and $(($TOTAL_FILES - 20)) more files"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show package info
|
||||||
|
echo "Package information:"
|
||||||
|
echo "======================================================================"
|
||||||
|
dpkg-deb -I "$DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run lintian if available
|
||||||
|
if [ -z "$SKIP_LINTIAN" ]; then
|
||||||
|
echo "Running lintian checks..."
|
||||||
|
echo "======================================================================"
|
||||||
|
|
||||||
|
# Run lintian - allow warnings but fail on errors
|
||||||
|
if lintian "$DEB_FILE"; then
|
||||||
|
echo -e "${GREEN}Lintian passed!${NC}"
|
||||||
|
else
|
||||||
|
LINTIAN_EXIT=$?
|
||||||
|
echo -e "${YELLOW}Lintian found issues (exit code: $LINTIAN_EXIT)${NC}"
|
||||||
|
echo "Review the output above. Warnings are acceptable, errors should be fixed."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final summary
|
||||||
|
echo "======================================================================"
|
||||||
|
echo -e "${GREEN}Package verification complete!${NC}"
|
||||||
|
echo "======================================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Package location: $DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
echo "To install on a Raspberry Pi:"
|
||||||
|
echo " scp $DEB_FILE pi@raspberrypi:~/"
|
||||||
|
echo " ssh pi@raspberrypi"
|
||||||
|
echo " sudo apt install ./$(basename $DEB_FILE)"
|
||||||
|
echo ""
|
||||||
|
echo "To test locally (not recommended, will modify /opt and /etc):"
|
||||||
|
echo " sudo apt install $DEB_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -5,6 +5,6 @@ Monitors Xiaomi Mijia LYWSD03MMC Bluetooth temperature sensors
|
|||||||
and publishes data to MQTT broker.
|
and publishes data to MQTT broker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.0.0-dev"
|
__version__ = "3.0.0"
|
||||||
__author__ = "Fredrik"
|
__author__ = "Fredrik"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
||||||
|
|||||||
@@ -153,8 +153,10 @@ def approve_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
|
|||||||
print(f" Name: {name}")
|
print(f" Name: {name}")
|
||||||
print(f" Configuration will be reloaded automatically within 15 minutes")
|
print(f" Configuration will be reloaded automatically within 15 minutes")
|
||||||
|
|
||||||
# Mark as approved in discovery manager
|
# Mark as approved in discovery manager and save
|
||||||
|
print(f"\nUpdating discovery status...")
|
||||||
manager.approve(sensor.mac)
|
manager.approve(sensor.mac)
|
||||||
|
print(f"✅ Marked as approved in discovered_sensors.json")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ Error saving to sensors.json: {e}")
|
print(f"\n❌ Error saving to sensors.json: {e}")
|
||||||
@@ -172,7 +174,7 @@ def ignore_sensor(sensor: DiscoveredSensor, manager: DiscoveryManager):
|
|||||||
|
|
||||||
manager.ignore(sensor.mac, reason if reason else None)
|
manager.ignore(sensor.mac, reason if reason else None)
|
||||||
|
|
||||||
print(f"\n✅ Sensor ignored")
|
print(f"\n✅ Sensor ignored and marked in discovered_sensors.json")
|
||||||
if reason:
|
if reason:
|
||||||
print(f" Reason: {reason}")
|
print(f" Reason: {reason}")
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,36 @@ 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 (3 levels up from this file: src/sensorpajen/config.py)
|
# Determine project root and config directory
|
||||||
|
# Check if running from system installation (/opt/sensorpajen)
|
||||||
|
current_file = Path(__file__).resolve()
|
||||||
|
is_system_install = str(current_file).startswith('/opt/sensorpajen')
|
||||||
|
|
||||||
|
if is_system_install:
|
||||||
|
# System installation
|
||||||
|
PROJECT_ROOT = Path('/opt/sensorpajen')
|
||||||
|
CONFIG_DIR = Path('/etc/sensorpajen')
|
||||||
|
STATE_DIR = Path('/var/lib/sensorpajen')
|
||||||
|
else:
|
||||||
|
# Development installation
|
||||||
|
# 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"
|
||||||
|
STATE_DIR = CONFIG_DIR
|
||||||
|
|
||||||
# MQTT Configuration from environment
|
# MQTT Configuration from environment
|
||||||
MQTT_HOST = os.environ.get("MQTT_HOST")
|
MQTT_HOST = os.environ.get("MQTT_HOST")
|
||||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||||
@@ -24,17 +47,18 @@ 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. "
|
||||||
"Please configure config/sensorpajen.env"
|
"Please configure config/sensorpajen.env"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sensor configuration file (relative to project root)
|
# Sensor configuration file
|
||||||
SENSOR_CONFIG_FILE = os.environ.get(
|
SENSOR_CONFIG_FILE = os.environ.get(
|
||||||
"SENSOR_CONFIG_FILE",
|
"SENSOR_CONFIG_FILE",
|
||||||
str(PROJECT_ROOT / "config/sensors.json")
|
str(CONFIG_DIR / "sensors.json")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Application settings
|
# Application settings
|
||||||
@@ -55,7 +79,11 @@ NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
|
|||||||
# Discovery settings
|
# Discovery settings
|
||||||
DISCOVERED_SENSORS_FILE = os.environ.get(
|
DISCOVERED_SENSORS_FILE = os.environ.get(
|
||||||
"DISCOVERED_SENSORS_FILE",
|
"DISCOVERED_SENSORS_FILE",
|
||||||
str(PROJECT_ROOT / "config/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
|
||||||
|
|
||||||
@@ -72,16 +100,17 @@ class SensorConfig:
|
|||||||
"""
|
"""
|
||||||
self.config_file = Path(config_file)
|
self.config_file = Path(config_file)
|
||||||
self.sensors: Dict[str, str] = {}
|
self.sensors: Dict[str, str] = {}
|
||||||
|
self.comments: Dict[str, str] = {}
|
||||||
self.load()
|
self.load()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
"""Load sensor configuration from JSON file."""
|
"""Load sensor configuration from JSON file."""
|
||||||
if not self.config_file.exists():
|
if not self.config_file.exists():
|
||||||
raise FileNotFoundError(
|
logger.warning(
|
||||||
f"Sensor configuration file not found: {self.config_file}\n"
|
f"Sensor configuration file not found: {self.config_file}\n"
|
||||||
f"Please copy config/sensors.json.example to config/sensors.json "
|
f"Starting with no sensors - use discovery to add sensors"
|
||||||
f"and configure your sensors."
|
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.config_file, 'r') as f:
|
with open(self.config_file, 'r') as f:
|
||||||
@@ -91,9 +120,12 @@ class SensorConfig:
|
|||||||
for sensor in data.get('sensors', []):
|
for sensor in data.get('sensors', []):
|
||||||
mac = sensor.get('mac', '').upper()
|
mac = sensor.get('mac', '').upper()
|
||||||
name = sensor.get('name')
|
name = sensor.get('name')
|
||||||
|
comment = sensor.get('comment')
|
||||||
|
|
||||||
if mac and name:
|
if mac and name:
|
||||||
self.sensors[mac] = name
|
self.sensors[mac] = name
|
||||||
|
if isinstance(comment, str) and comment != "":
|
||||||
|
self.comments[mac] = comment
|
||||||
logger.debug(f"Loaded sensor: {mac} -> {name}")
|
logger.debug(f"Loaded sensor: {mac} -> {name}")
|
||||||
|
|
||||||
logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}")
|
logger.info(f"Loaded {len(self.sensors)} sensors from {self.config_file}")
|
||||||
@@ -119,13 +151,190 @@ 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 get_comment(self, mac: str) -> Optional[str]:
|
||||||
|
"""Get sensor comment by MAC address, if present."""
|
||||||
|
return self.comments.get(mac.upper())
|
||||||
|
|
||||||
|
def add_sensor(self, mac: str, name: str, comment: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Add or update a sensor in the configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mac: MAC address
|
||||||
|
name: Sensor name
|
||||||
|
comment: Optional comment
|
||||||
|
"""
|
||||||
|
mac = mac.upper()
|
||||||
|
logger.debug(f"add_sensor called: MAC={mac}, name={name}")
|
||||||
|
self.sensors[mac] = name
|
||||||
|
if comment is not None:
|
||||||
|
# Allow explicit clearing by passing empty string
|
||||||
|
if comment == "":
|
||||||
|
self.comments.pop(mac, None)
|
||||||
|
else:
|
||||||
|
self.comments[mac] = comment
|
||||||
|
logger.debug(f"Updated in-memory dict: {mac} -> {name}")
|
||||||
|
logger.debug(f"Current sensors dict: {self.sensors}")
|
||||||
|
try:
|
||||||
|
self.save(mac, name, comment)
|
||||||
|
logger.info(f"Successfully saved sensor {mac}={name}")
|
||||||
|
except Exception as e:
|
||||||
|
# If save fails, remove from memory too
|
||||||
|
logger.error(f"Failed to save sensor {mac}: {e}")
|
||||||
|
if mac in self.sensors:
|
||||||
|
del self.sensors[mac]
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def remove_sensor(self, mac: str):
|
||||||
|
"""
|
||||||
|
Remove a sensor from the configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mac: MAC address
|
||||||
|
"""
|
||||||
|
mac = mac.upper()
|
||||||
|
if mac in self.sensors:
|
||||||
|
del self.sensors[mac]
|
||||||
|
self.comments.pop(mac, None)
|
||||||
|
|
||||||
|
# Load current file, remove entry, and save
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
logger.debug(f"save() called for MAC={mac}, name={name}")
|
||||||
|
data = {"sensors": []}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.config_file.exists():
|
||||||
|
logger.debug(f"Reading existing config from {self.config_file}")
|
||||||
|
with open(self.config_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
logger.debug(f"Loaded config with {len(data.get('sensors', []))} sensors")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Config file does not exist: {self.config_file}")
|
||||||
|
|
||||||
|
sensors = data.get('sensors', [])
|
||||||
|
# Update existing or add new
|
||||||
|
found = False
|
||||||
|
for s in sensors:
|
||||||
|
if s.get('mac', '').upper() == mac:
|
||||||
|
logger.debug(f"Found existing sensor entry for {mac}, updating name")
|
||||||
|
s['name'] = name
|
||||||
|
if comment is not None:
|
||||||
|
if comment == "":
|
||||||
|
s.pop('comment', None)
|
||||||
|
else:
|
||||||
|
s['comment'] = comment
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
logger.debug(f"Sensor {mac} not found in config, adding new entry")
|
||||||
|
new_sensor = {"mac": mac, "name": name}
|
||||||
|
if comment is not None and 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)
|
||||||
|
|
||||||
|
logger.debug(f"Writing {len(sensors)} sensors to {self.config_file}")
|
||||||
|
with open(self.config_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
logger.info(f"Saved sensor {mac} to {self.config_file}")
|
||||||
|
|
||||||
|
# Verify the write
|
||||||
|
with open(self.config_file, 'r') as f:
|
||||||
|
saved_data = json.load(f)
|
||||||
|
saved_sensors = saved_data.get('sensors', [])
|
||||||
|
logger.debug(f"Verification: File now contains {len(saved_sensors)} sensors")
|
||||||
|
for s in saved_sensors:
|
||||||
|
if s.get('mac', '').upper() == mac:
|
||||||
|
logger.debug(f"Verification: Found {mac} in file with name={s.get('name')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving sensor config: {e}", exc_info=True)
|
||||||
|
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"
|
||||||
logger.info("=== Sensorpajen Configuration ===")
|
logger.info("=== Sensorpajen Configuration ===")
|
||||||
|
logger.info(f"Installation Type: {install_type}")
|
||||||
|
logger.info(f"Project Root: {PROJECT_ROOT}")
|
||||||
|
logger.info(f"Config Directory: {CONFIG_DIR}")
|
||||||
|
logger.info(f"State Directory: {STATE_DIR}")
|
||||||
logger.info(f"MQTT Host: {MQTT_HOST}:{MQTT_PORT}")
|
logger.info(f"MQTT Host: {MQTT_HOST}:{MQTT_PORT}")
|
||||||
logger.info(f"MQTT User: {MQTT_USER}")
|
logger.info(f"MQTT User: {MQTT_USER}")
|
||||||
logger.info(f"MQTT Client ID: {MQTT_CLIENT_ID}")
|
logger.info(f"MQTT Client ID: {MQTT_CLIENT_ID}")
|
||||||
|
|||||||
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
|
||||||
@@ -240,7 +178,7 @@ class DiscoveryManager:
|
|||||||
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C\n"
|
f"Temp: {sensor.sample_reading.get('temperature', 'N/A')}°C\n"
|
||||||
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%\n"
|
f"Humidity: {sensor.sample_reading.get('humidity', 'N/A')}%\n"
|
||||||
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%\n\n"
|
f"Battery: {sensor.sample_reading.get('battery_percent', 'N/A')}%\n\n"
|
||||||
f"Run 'sensorpajen approve-sensors' to approve or ignore."
|
f"Run 'sensorpajen-tui' to approve or ignore."
|
||||||
)
|
)
|
||||||
|
|
||||||
url = f"{config.NTFY_URL}/{config.NTFY_TOPIC}"
|
url = f"{config.NTFY_URL}/{config.NTFY_TOPIC}"
|
||||||
|
|||||||
@@ -125,13 +125,13 @@ class Sensorpajen:
|
|||||||
self.sensor_config = config.SensorConfig()
|
self.sensor_config = config.SensorConfig()
|
||||||
|
|
||||||
if len(self.sensor_config.sensors) == 0:
|
if len(self.sensor_config.sensors) == 0:
|
||||||
self.logger.error("No sensors configured!")
|
self.logger.warning("No sensors configured")
|
||||||
self.logger.error("Please configure sensors in config/sensors.json")
|
self.logger.warning("Starting in discovery-only mode")
|
||||||
sys.exit(1)
|
self.logger.warning("Use 'sensorpajen-tui' to add sensors")
|
||||||
|
|
||||||
# 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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
700
src/sensorpajen/tui/app.py
Normal file
700
src/sensorpajen/tui/app.py
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.widgets import Header, Footer, TabbedContent, TabPane, DataTable, Static, Button
|
||||||
|
from textual.containers import Container, Horizontal
|
||||||
|
from textual import on
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from ..discovery_manager import DiscoveryManager
|
||||||
|
from ..config import SensorConfig, save_env_var
|
||||||
|
from .modals import InputModal, ConfirmModal, DetailsModal, EditSensorModal
|
||||||
|
|
||||||
|
|
||||||
|
def _format_metadata_comment(mac: str, name: str, last_seen: str, sample_reading: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"MAC: {mac}, "
|
||||||
|
f"Name: {name}, "
|
||||||
|
f"Last seen: {last_seen}, "
|
||||||
|
f"Temp: {sample_reading.get('temperature', 'N/A')}°C, "
|
||||||
|
f"Humidity: {sample_reading.get('humidity', 'N/A')}%, "
|
||||||
|
f"Battery: {sample_reading.get('battery_percent', 'N/A')}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_tui_file_logging() -> logging.Logger:
|
||||||
|
"""Log to a file (not stdout/stderr) to avoid breaking Textual fullscreen UI."""
|
||||||
|
logger = logging.getLogger("sensorpajen.tui")
|
||||||
|
if logger.handlers:
|
||||||
|
return logger
|
||||||
|
|
||||||
|
log_file = Path(os.environ.get("TUI_LOG_FILE", "dev_backend.log"))
|
||||||
|
if not log_file.is_absolute():
|
||||||
|
log_file = Path.cwd() / log_file
|
||||||
|
try:
|
||||||
|
handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||||
|
)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
logger.info("TUI logging enabled -> %s", log_file)
|
||||||
|
except Exception as exc:
|
||||||
|
# If we can't write logs, keep UI working.
|
||||||
|
try:
|
||||||
|
logger.setLevel(logging.CRITICAL)
|
||||||
|
logger.propagate = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
tui_logger = _setup_tui_file_logging()
|
||||||
|
|
||||||
|
class SensorpajenApp(App):
|
||||||
|
"""A Textual app to manage Bluetooth sensors."""
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Use priority bindings so keys still reach the App even when a DataTable has focus.
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("q", "quit", "Quit", priority=True),
|
||||||
|
Binding("d", "toggle_dark", "Toggle dark mode", priority=True),
|
||||||
|
Binding("r", "refresh", "Refresh data", priority=True),
|
||||||
|
Binding("a", "approve", "Approve", priority=True),
|
||||||
|
Binding("i", "ignore", "Ignore", priority=True),
|
||||||
|
Binding("e", "edit", "Edit", priority=True),
|
||||||
|
Binding("v", "view_details", "Details", priority=True),
|
||||||
|
Binding("u", "unignore", "Unignore", priority=True),
|
||||||
|
Binding("delete", "remove", "Remove", priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.sensor_config = SensorConfig()
|
||||||
|
# Pass sensor_config to discovery manager for filtering
|
||||||
|
self.discovery_manager = DiscoveryManager(sensor_config=self.sensor_config)
|
||||||
|
try:
|
||||||
|
tui_logger.info(
|
||||||
|
"TUI init: sensors_file=%s, configured=%d",
|
||||||
|
getattr(self.sensor_config, "config_file", None),
|
||||||
|
len(getattr(self.sensor_config, "sensors", {})),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Create child widgets for the app."""
|
||||||
|
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 _open_input_modal(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
initial_value: str = "",
|
||||||
|
placeholder: str = "",
|
||||||
|
on_result: Callable[[Optional[str]], None],
|
||||||
|
) -> None:
|
||||||
|
"""Open an input modal and handle the result via callback.
|
||||||
|
|
||||||
|
This avoids awaiting modal results inside action handlers, which can
|
||||||
|
freeze / deadlock depending on Textual version and context.
|
||||||
|
"""
|
||||||
|
modal = InputModal(title, placeholder=placeholder, initial_value=initial_value)
|
||||||
|
self.push_screen(modal, on_result)
|
||||||
|
|
||||||
|
def _open_confirm_modal(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
on_result: Callable[[bool], None],
|
||||||
|
confirm_label: str = "Yes",
|
||||||
|
cancel_label: str = "No",
|
||||||
|
) -> None:
|
||||||
|
modal = ConfirmModal(
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirm_label=confirm_label,
|
||||||
|
cancel_label=cancel_label,
|
||||||
|
)
|
||||||
|
self.push_screen(modal, on_result)
|
||||||
|
|
||||||
|
def _open_details_modal(self, title: str, details_text: str) -> None:
|
||||||
|
modal = DetailsModal(title, details_text)
|
||||||
|
# No callback needed
|
||||||
|
self.push_screen(modal)
|
||||||
|
|
||||||
|
async def _save_sensor(self, mac: str, name: str, comment: Optional[str] = None) -> None:
|
||||||
|
await asyncio.to_thread(self.sensor_config.add_sensor, mac, name, comment)
|
||||||
|
|
||||||
|
async def _remove_sensor(self, mac: str) -> None:
|
||||||
|
await asyncio.to_thread(self.sensor_config.remove_sensor, mac)
|
||||||
|
|
||||||
|
def action_refresh(self) -> None:
|
||||||
|
"""Refresh all tables."""
|
||||||
|
self.refresh_data()
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Get a richer sensor object for metadata (best-effort)
|
||||||
|
sensor_obj = None
|
||||||
|
try:
|
||||||
|
for s in self.discovery_manager.get_pending():
|
||||||
|
if getattr(s, "mac", "").upper() == str(mac).upper():
|
||||||
|
sensor_obj = s
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
sensor_obj = None
|
||||||
|
|
||||||
|
default_comment = _format_metadata_comment(
|
||||||
|
str(mac),
|
||||||
|
getattr(sensor_obj, "name", default_name),
|
||||||
|
getattr(sensor_obj, "last_seen", "N/A"),
|
||||||
|
getattr(sensor_obj, "sample_reading", {}) or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_result(result: object) -> None:
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
name, comment = result # type: ignore[misc]
|
||||||
|
except Exception:
|
||||||
|
self.notify("Invalid approve result", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
name_stripped = str(name).strip()
|
||||||
|
if not name_stripped:
|
||||||
|
self.notify("Sensor name cannot be empty", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Match legacy behavior: empty comment falls back to default metadata.
|
||||||
|
comment_stripped = str(comment).strip()
|
||||||
|
comment_to_use = comment_stripped if comment_stripped else default_comment
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
try:
|
||||||
|
await self._save_sensor(str(mac), name_stripped, comment_to_use)
|
||||||
|
self.discovery_manager.approve(str(mac))
|
||||||
|
self.notify(f"Approved {mac} as {name_stripped}")
|
||||||
|
self.refresh_data()
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error approving sensor: {e}", severity="error")
|
||||||
|
|
||||||
|
asyncio.create_task(_do())
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
EditSensorModal(title="Approve sensor", name=str(default_name), comment=default_comment),
|
||||||
|
_on_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def action_ignore(self) -> None:
|
||||||
|
"""Ignore the selected discovered sensor."""
|
||||||
|
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]
|
||||||
|
|
||||||
|
def _on_reason(reason: Optional[str]) -> None:
|
||||||
|
# Allow empty string but not None (Cancel)
|
||||||
|
if reason is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.discovery_manager.ignore(mac, reason if reason else None)
|
||||||
|
self.notify(f"Ignored {mac}")
|
||||||
|
self.refresh_data()
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error ignoring sensor: {e}", severity="error")
|
||||||
|
|
||||||
|
self._open_input_modal("Enter ignore reason (optional)", on_result=_on_reason)
|
||||||
|
|
||||||
|
async def action_edit(self) -> None:
|
||||||
|
"""Edit the selected item (sensor or setting)."""
|
||||||
|
active_tab = self.query_one(TabbedContent).active
|
||||||
|
|
||||||
|
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 = str(row[0]).upper() # Ensure MAC is uppercase
|
||||||
|
current_name = str(row[1])
|
||||||
|
current_comment = self.sensor_config.get_comment(mac) or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
tui_logger.info(
|
||||||
|
"Edit configured: mac=%s current_name=%r file=%s",
|
||||||
|
mac,
|
||||||
|
current_name,
|
||||||
|
getattr(self.sensor_config, "config_file", None),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
current_name_stripped = current_name.strip()
|
||||||
|
current_comment_stripped = current_comment.strip()
|
||||||
|
|
||||||
|
def _on_result(result: object) -> None:
|
||||||
|
if result is None:
|
||||||
|
try:
|
||||||
|
tui_logger.info("Edit cancelled: mac=%s", mac)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_name, new_comment = result # type: ignore[misc]
|
||||||
|
except Exception:
|
||||||
|
self.notify("Invalid edit result", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_name = str(new_name).strip()
|
||||||
|
new_comment = str(new_comment).strip()
|
||||||
|
|
||||||
|
if not new_name:
|
||||||
|
self.notify("Sensor name cannot be empty", severity="error")
|
||||||
|
return
|
||||||
|
|
||||||
|
name_changed = new_name != current_name_stripped
|
||||||
|
comment_changed = new_comment != current_comment_stripped
|
||||||
|
|
||||||
|
if not name_changed and not comment_changed:
|
||||||
|
try:
|
||||||
|
tui_logger.info("Edit no-op: mac=%s name/comment unchanged", mac)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only touch comment if user changed it; empty string means clear.
|
||||||
|
comment_to_pass: Optional[str]
|
||||||
|
if comment_changed:
|
||||||
|
comment_to_pass = new_comment
|
||||||
|
else:
|
||||||
|
comment_to_pass = None
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
try:
|
||||||
|
await self._save_sensor(mac, new_name, comment_to_pass)
|
||||||
|
stored_name = self.sensor_config.sensors.get(mac)
|
||||||
|
try:
|
||||||
|
tui_logger.info(
|
||||||
|
"Edit result: mac=%s new_name=%r stored_name=%r new_comment=%r",
|
||||||
|
mac,
|
||||||
|
new_name,
|
||||||
|
stored_name,
|
||||||
|
comment_to_pass,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.notify(f"Updated {mac}")
|
||||||
|
self.refresh_data()
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
tui_logger.exception("Error updating sensor: mac=%s", mac)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.notify(f"Error updating sensor: {e}", severity="error")
|
||||||
|
|
||||||
|
asyncio.create_task(_do())
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
EditSensorModal(name=current_name, comment=current_comment),
|
||||||
|
_on_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif active_tab == "settings":
|
||||||
|
table = self.query_one("#settings-table", DataTable)
|
||||||
|
if table.cursor_row is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
row = table.get_row_at(table.cursor_row)
|
||||||
|
key = row[0]
|
||||||
|
current_value = row[1]
|
||||||
|
|
||||||
|
def _on_value(new_value: Optional[str]) -> None:
|
||||||
|
if new_value is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
save_env_var(key, new_value)
|
||||||
|
# Update current runtime env for display (won't take effect in backend until restart)
|
||||||
|
import os
|
||||||
|
os.environ[key] = new_value
|
||||||
|
self.notify(
|
||||||
|
f"Updated {key}. Restart service for changes to take effect!",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
self.refresh_data()
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error saving setting: {e}", severity="error")
|
||||||
|
|
||||||
|
self._open_input_modal(f"Edit {key}", initial_value=str(current_value), on_result=_on_value)
|
||||||
|
|
||||||
|
def action_view_details(self) -> None:
|
||||||
|
"""View details for the selected sensor (shows long comment in popup)."""
|
||||||
|
active_tab = self.query_one(TabbedContent).active
|
||||||
|
table_id = None
|
||||||
|
|
||||||
|
if active_tab == "configured":
|
||||||
|
table_id = "#configured-table"
|
||||||
|
elif active_tab == "discovery":
|
||||||
|
table_id = "#discovery-table"
|
||||||
|
elif active_tab == "ignored":
|
||||||
|
table_id = "#ignored-table"
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
table = self.query_one(table_id, DataTable)
|
||||||
|
if table.cursor_row is None:
|
||||||
|
self.notify("Select a sensor first", severity="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
row = table.get_row_at(table.cursor_row)
|
||||||
|
mac = str(row[0]).upper()
|
||||||
|
name = str(row[1]) if len(row) > 1 else ""
|
||||||
|
|
||||||
|
comment = None
|
||||||
|
if active_tab == "configured":
|
||||||
|
comment = self.sensor_config.get_comment(mac)
|
||||||
|
|
||||||
|
details_lines = [
|
||||||
|
f"MAC: {mac}",
|
||||||
|
f"Name: {name}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if comment:
|
||||||
|
details_lines.extend(["", "Comment:", comment])
|
||||||
|
else:
|
||||||
|
details_lines.extend(["", "Comment:", "(none)"])
|
||||||
|
|
||||||
|
self._open_details_modal("Sensor details", "\n".join(details_lines))
|
||||||
|
|
||||||
|
def action_remove(self) -> None:
|
||||||
|
"""Remove the selected configured sensor."""
|
||||||
|
if self.query_one(TabbedContent).active != "configured":
|
||||||
|
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]
|
||||||
|
|
||||||
|
def _on_confirm(confirmed: bool) -> None:
|
||||||
|
if not confirmed:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _do() -> None:
|
||||||
|
try:
|
||||||
|
await self._remove_sensor(str(mac))
|
||||||
|
|
||||||
|
# Also need to reset its status in DiscoveryManager to make it show up in Discovery again
|
||||||
|
self.discovery_manager.unignore(str(mac)) # unignore sets status to 'pending'
|
||||||
|
|
||||||
|
self.notify(f"Removed {mac}")
|
||||||
|
self.refresh_data()
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error removing sensor: {e}", severity="error")
|
||||||
|
|
||||||
|
asyncio.create_task(_do())
|
||||||
|
|
||||||
|
self._open_confirm_modal(
|
||||||
|
"Remove sensor",
|
||||||
|
f"Remove {mac} from configured sensors?",
|
||||||
|
confirm_label="Remove",
|
||||||
|
cancel_label="Cancel",
|
||||||
|
on_result=_on_confirm,
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_unignore(self) -> None:
|
||||||
|
"""Unignore the selected sensor."""
|
||||||
|
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."""
|
||||||
|
try:
|
||||||
|
self._update_discovery_table()
|
||||||
|
self._update_configured_table()
|
||||||
|
self._update_ignored_table()
|
||||||
|
self._update_settings_table()
|
||||||
|
self._update_dashboard()
|
||||||
|
except Exception as e:
|
||||||
|
self.notify(f"Error refreshing data: {e}", severity="error")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
def _update_discovery_table(self) -> None:
|
||||||
|
table = self.query_one("#discovery-table", DataTable)
|
||||||
|
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()
|
||||||
142
src/sensorpajen/tui/modals.py
Normal file
142
src/sensorpajen/tui/modals.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.widgets import Input, Label, Button
|
||||||
|
from textual.containers import Vertical, Horizontal, VerticalScroll
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmModal(ModalScreen[bool]):
|
||||||
|
"""A modal screen for confirming an action."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
confirm_label: str = "Yes",
|
||||||
|
cancel_label: str = "No",
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.title_text = title
|
||||||
|
self.message_text = message
|
||||||
|
self.confirm_label = confirm_label
|
||||||
|
self.cancel_label = cancel_label
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="modal-container"):
|
||||||
|
yield Label(self.title_text)
|
||||||
|
yield Label(self.message_text)
|
||||||
|
with Horizontal(id="modal-buttons"):
|
||||||
|
yield Button(self.confirm_label, variant="warning", id="confirm-btn")
|
||||||
|
yield Button(self.cancel_label, variant="primary", id="cancel-btn")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#cancel-btn", Button).focus()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "confirm-btn":
|
||||||
|
self.dismiss(True)
|
||||||
|
else:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
|
||||||
|
class DetailsModal(ModalScreen[None]):
|
||||||
|
"""A modal screen for showing potentially long details text."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "close", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, title: str, details_text: str):
|
||||||
|
super().__init__()
|
||||||
|
self.title_text = title
|
||||||
|
self.details_text = details_text
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="modal-container"):
|
||||||
|
yield Label(self.title_text)
|
||||||
|
with VerticalScroll():
|
||||||
|
yield Static(self.details_text)
|
||||||
|
with Horizontal(id="modal-buttons"):
|
||||||
|
yield Button("Close", variant="primary", id="close-btn")
|
||||||
|
|
||||||
|
def action_close(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#close-btn", Button).focus()
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
|
||||||
|
class EditSensorModal(ModalScreen):
|
||||||
|
"""A modal screen for editing a sensor's name and comment."""
|
||||||
|
|
||||||
|
def __init__(self, *, title: str = "Edit sensor", name: str, comment: str):
|
||||||
|
super().__init__()
|
||||||
|
self.title_text = title
|
||||||
|
self.initial_name = name
|
||||||
|
self.initial_comment = comment
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="modal-container"):
|
||||||
|
yield Label(self.title_text)
|
||||||
|
yield Label("Name")
|
||||||
|
yield Input(value=self.initial_name, id="sensor-name-input")
|
||||||
|
yield Label("Comment")
|
||||||
|
yield Input(value=self.initial_comment, placeholder="Optional comment", id="sensor-comment-input")
|
||||||
|
with Horizontal(id="modal-buttons"):
|
||||||
|
yield Button("OK", variant="primary", id="ok-btn")
|
||||||
|
yield Button("Cancel", variant="error", id="cancel-btn")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#sensor-name-input", Input).focus()
|
||||||
|
|
||||||
|
def _dismiss_with_values(self) -> None:
|
||||||
|
name = self.query_one("#sensor-name-input", Input).value
|
||||||
|
comment = self.query_one("#sensor-comment-input", Input).value
|
||||||
|
self.dismiss((name, comment))
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "ok-btn":
|
||||||
|
self._dismiss_with_values()
|
||||||
|
else:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
# Enter on name moves to comment; Enter on comment submits.
|
||||||
|
if event.input.id == "sensor-name-input":
|
||||||
|
self.query_one("#sensor-comment-input", Input).focus()
|
||||||
|
else:
|
||||||
|
self._dismiss_with_values()
|
||||||
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
|
||||||
98
tests/test_config.py
Normal file
98
tests/test_config.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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_comment_load_and_clear(tmp_path):
|
||||||
|
import json
|
||||||
|
import sensorpajen.config as config
|
||||||
|
|
||||||
|
config_file = tmp_path / "sensors.json"
|
||||||
|
config_file.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sensors": [
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room", "comment": "hello"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor_cfg = config.SensorConfig(config_file=str(config_file))
|
||||||
|
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") == "hello"
|
||||||
|
|
||||||
|
# Clear comment explicitly (empty string means remove comment key)
|
||||||
|
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "")
|
||||||
|
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") is None
|
||||||
|
|
||||||
|
saved = json.loads(config_file.read_text())
|
||||||
|
assert saved["sensors"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||||
|
assert saved["sensors"][0]["name"] == "Living Room"
|
||||||
|
assert "comment" not in saved["sensors"][0]
|
||||||
|
|
||||||
|
def test_sensor_config_add_remove(tmp_path):
|
||||||
|
import sensorpajen.config as config
|
||||||
|
config_file = tmp_path / "sensors.json"
|
||||||
|
# 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"
|
||||||
|
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") == "Test comment"
|
||||||
|
|
||||||
|
# Verify persistence
|
||||||
|
sensor_cfg2 = config.SensorConfig(config_file=str(config_file))
|
||||||
|
assert sensor_cfg2.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
|
||||||
|
assert sensor_cfg2.get_comment("AA:BB:CC:DD:EE:FF") == "Test comment"
|
||||||
|
|
||||||
|
# Remove
|
||||||
|
sensor_cfg.remove_sensor("AA:BB:CC:DD:EE:FF")
|
||||||
|
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"
|
||||||
154
tests/test_discovery_manager.py
Normal file
154
tests/test_discovery_manager.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from sensorpajen.discovery_manager import DiscoveryManager, DiscoveredSensor
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyCompletedProcess:
|
||||||
|
def __init__(self, returncode: int = 0, stderr: bytes = b""):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
def test_discovery_manager_init(tmp_path):
|
||||||
|
db_file = tmp_path / "sensors.db"
|
||||||
|
manager = DiscoveryManager(str(db_file))
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_ntfy_notification_disabled(monkeypatch, tmp_path):
|
||||||
|
from sensorpajen import discovery_manager as dm_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", False)
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "token")
|
||||||
|
|
||||||
|
called = {"run": False}
|
||||||
|
|
||||||
|
def _fake_run(*args, **kwargs):
|
||||||
|
called["run"] = True
|
||||||
|
return _DummyCompletedProcess(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
|
||||||
|
|
||||||
|
manager = dm_mod.DiscoveryManager(str(tmp_path / "dummy.db"))
|
||||||
|
sensor = dm_mod.DiscoveredSensor(
|
||||||
|
mac="AA",
|
||||||
|
name="N",
|
||||||
|
rssi=-1,
|
||||||
|
first_seen="now",
|
||||||
|
last_seen="now",
|
||||||
|
sample_reading={"temperature": 1, "humidity": 2, "battery_percent": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.send_ntfy_notification(sensor)
|
||||||
|
assert called["run"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_ntfy_notification_missing_token(monkeypatch, tmp_path):
|
||||||
|
from sensorpajen import discovery_manager as dm_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", True)
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "")
|
||||||
|
|
||||||
|
called = {"run": False}
|
||||||
|
|
||||||
|
def _fake_run(*args, **kwargs):
|
||||||
|
called["run"] = True
|
||||||
|
return _DummyCompletedProcess(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
|
||||||
|
|
||||||
|
manager = dm_mod.DiscoveryManager(str(tmp_path / "dummy2.db"))
|
||||||
|
sensor = dm_mod.DiscoveredSensor(
|
||||||
|
mac="AA",
|
||||||
|
name="N",
|
||||||
|
rssi=-1,
|
||||||
|
first_seen="now",
|
||||||
|
last_seen="now",
|
||||||
|
sample_reading={"temperature": 1, "humidity": 2, "battery_percent": 3},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.send_ntfy_notification(sensor)
|
||||||
|
assert called["run"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_ntfy_notification_message_mentions_tui(monkeypatch, tmp_path):
|
||||||
|
from sensorpajen import discovery_manager as dm_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", True)
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "token")
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_URL", "https://ntfy.sh")
|
||||||
|
monkeypatch.setattr(dm_mod.config, "NTFY_TOPIC", "sensorpajen")
|
||||||
|
|
||||||
|
captured = {"args": None}
|
||||||
|
|
||||||
|
def _fake_run(args, capture_output=True, timeout=10):
|
||||||
|
captured["args"] = args
|
||||||
|
return _DummyCompletedProcess(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
|
||||||
|
|
||||||
|
manager = dm_mod.DiscoveryManager(str(tmp_path / "sensors.db"))
|
||||||
|
sensor = dm_mod.DiscoveredSensor(
|
||||||
|
mac="AA:BB",
|
||||||
|
name="ATC_123",
|
||||||
|
rssi=-1,
|
||||||
|
first_seen="2025-01-01T00:00:00",
|
||||||
|
last_seen="2025-01-01T00:00:00",
|
||||||
|
sample_reading={"temperature": 10, "humidity": 20, "battery_percent": 30},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.send_ntfy_notification(sensor)
|
||||||
|
|
||||||
|
assert captured["args"] is not None
|
||||||
|
# curl args: [..., "-d", message, url]
|
||||||
|
assert "-d" in captured["args"]
|
||||||
|
message = captured["args"][captured["args"].index("-d") + 1]
|
||||||
|
assert "sensorpajen-tui" in message
|
||||||
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()
|
||||||
225
tests/test_tui.py
Normal file
225
tests/test_tui.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from sensorpajen.config import SensorConfig
|
||||||
|
from sensorpajen.discovery_manager import DiscoveryManager
|
||||||
|
|
||||||
|
def test_tui_sensor_config_edit():
|
||||||
|
"""Integration test: Test that editing a sensor works end-to-end"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_file = Path(tmpdir) / "sensors.json"
|
||||||
|
db_file = Path(tmpdir) / "test.db"
|
||||||
|
|
||||||
|
# Create initial config
|
||||||
|
initial_data = {
|
||||||
|
"sensors": [
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room Sensor"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config_file.write_text(json.dumps(initial_data, indent=2))
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
conn = sqlite3.connect(str(db_file))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS discovered_sensors (
|
||||||
|
mac TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
rssi INTEGER,
|
||||||
|
first_seen TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
last_temp REAL,
|
||||||
|
last_humidity REAL,
|
||||||
|
last_battery_percent INTEGER,
|
||||||
|
last_battery_voltage INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
reviewed BOOLEAN DEFAULT 0,
|
||||||
|
ignored_at TIMESTAMP,
|
||||||
|
ignore_reason TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO discovered_sensors
|
||||||
|
(mac, name, rssi, first_seen, last_seen, count, last_temp, last_humidity,
|
||||||
|
last_battery_percent, last_battery_voltage, status, reviewed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', 1)
|
||||||
|
""", ("AA:BB:CC:DD:EE:FF", "Living Room Sensor", -65, now, now, 50, 23.5, 55, 85, 2950))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Load config and discovery manager (simulating TUI)
|
||||||
|
config = SensorConfig(str(config_file))
|
||||||
|
dm = DiscoveryManager(str(db_file), config)
|
||||||
|
|
||||||
|
# Verify initial state
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room Sensor"
|
||||||
|
|
||||||
|
# Edit sensor (simulate user action in TUI)
|
||||||
|
config.add_sensor("AA:BB:CC:DD:EE:FF", "Bedroom Sensor")
|
||||||
|
|
||||||
|
# Verify in-memory update
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Bedroom Sensor"
|
||||||
|
|
||||||
|
# Verify disk update
|
||||||
|
saved_data = json.loads(config_file.read_text())
|
||||||
|
assert saved_data["sensors"][0]["name"] == "Bedroom Sensor"
|
||||||
|
|
||||||
|
# Simulate refresh_data() - create new config instance and verify
|
||||||
|
config2 = SensorConfig(str(config_file))
|
||||||
|
assert config2.sensors["AA:BB:CC:DD:EE:FF"] == "Bedroom Sensor"
|
||||||
|
|
||||||
|
def test_sensor_config_edit_updates_memory():
|
||||||
|
"""Test that editing a sensor updates both disk and memory"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_file = Path(tmpdir) / "sensors.json"
|
||||||
|
|
||||||
|
# Create initial config
|
||||||
|
initial_data = {
|
||||||
|
"sensors": [
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Original Name"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config_file.write_text(json.dumps(initial_data, indent=2))
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = SensorConfig(str(config_file))
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Original Name"
|
||||||
|
|
||||||
|
# Edit sensor
|
||||||
|
config.add_sensor("AA:BB:CC:DD:EE:FF", "Updated Name")
|
||||||
|
|
||||||
|
# Check in-memory is updated
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Updated Name"
|
||||||
|
|
||||||
|
# Check disk is updated
|
||||||
|
saved_data = json.loads(config_file.read_text())
|
||||||
|
assert saved_data["sensors"][0]["name"] == "Updated Name"
|
||||||
|
|
||||||
|
# Reload from disk and verify
|
||||||
|
config2 = SensorConfig(str(config_file))
|
||||||
|
assert config2.sensors["AA:BB:CC:DD:EE:FF"] == "Updated Name"
|
||||||
|
|
||||||
|
def test_sensor_config_remove_sensor():
|
||||||
|
"""Test that removing a sensor works correctly"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_file = Path(tmpdir) / "sensors.json"
|
||||||
|
|
||||||
|
# Create config with multiple sensors
|
||||||
|
initial_data = {
|
||||||
|
"sensors": [
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Sensor 1"},
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:11", "name": "Sensor 2"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config_file.write_text(json.dumps(initial_data, indent=2))
|
||||||
|
|
||||||
|
# Load and verify
|
||||||
|
config = SensorConfig(str(config_file))
|
||||||
|
assert len(config.sensors) == 2
|
||||||
|
|
||||||
|
# Remove one sensor
|
||||||
|
config.remove_sensor("AA:BB:CC:DD:EE:FF")
|
||||||
|
|
||||||
|
# Check in-memory removal
|
||||||
|
assert "AA:BB:CC:DD:EE:FF" not in config.sensors
|
||||||
|
assert "AA:BB:CC:DD:EE:11" in config.sensors
|
||||||
|
|
||||||
|
# Check disk update
|
||||||
|
saved_data = json.loads(config_file.read_text())
|
||||||
|
assert len(saved_data["sensors"]) == 1
|
||||||
|
assert saved_data["sensors"][0]["mac"] == "AA:BB:CC:DD:EE:11"
|
||||||
|
|
||||||
|
def test_sensor_config_reload():
|
||||||
|
"""Test that reload() re-reads from disk"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_file = Path(tmpdir) / "sensors.json"
|
||||||
|
|
||||||
|
# Create initial config
|
||||||
|
initial_data = {
|
||||||
|
"sensors": [
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Original Name"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config_file.write_text(json.dumps(initial_data, indent=2))
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
config = SensorConfig(str(config_file))
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Original Name"
|
||||||
|
|
||||||
|
# Manually modify file on disk
|
||||||
|
new_data = {
|
||||||
|
"sensors": [
|
||||||
|
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Externally Modified"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config_file.write_text(json.dumps(new_data, indent=2))
|
||||||
|
|
||||||
|
# Reload should pick up the changes
|
||||||
|
config.load()
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Externally Modified"
|
||||||
|
|
||||||
|
def test_discovery_manager_approve_sensor():
|
||||||
|
"""Test that approving a sensor works correctly"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
config_file = Path(tmpdir) / "sensors.json"
|
||||||
|
db_file = Path(tmpdir) / "test.db"
|
||||||
|
|
||||||
|
# Create empty config
|
||||||
|
config_file.write_text(json.dumps({"sensors": []}, indent=2))
|
||||||
|
|
||||||
|
# Initialize database with pending sensor
|
||||||
|
conn = sqlite3.connect(str(db_file))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS discovered_sensors (
|
||||||
|
mac TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
rssi INTEGER,
|
||||||
|
first_seen TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
last_temp REAL,
|
||||||
|
last_humidity REAL,
|
||||||
|
last_battery_percent INTEGER,
|
||||||
|
last_battery_voltage INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
reviewed BOOLEAN DEFAULT 0,
|
||||||
|
ignored_at TIMESTAMP,
|
||||||
|
ignore_reason TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
now = datetime.now().isoformat()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO discovered_sensors
|
||||||
|
(mac, name, rssi, first_seen, last_seen, count, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", ("AA:BB:CC:DD:EE:33", "Unknown Sensor", -80, now, now, 1, "pending"))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Load config and DM
|
||||||
|
config = SensorConfig(str(config_file))
|
||||||
|
dm = DiscoveryManager(str(db_file), config)
|
||||||
|
|
||||||
|
# Verify sensor is pending
|
||||||
|
pending = dm.get_pending()
|
||||||
|
assert len(pending) == 1
|
||||||
|
assert pending[0].mac == "AA:BB:CC:DD:EE:33"
|
||||||
|
|
||||||
|
# Approve and add to config (simulate TUI action)
|
||||||
|
config.add_sensor("AA:BB:CC:DD:EE:33", "Kitchen Sensor")
|
||||||
|
dm.approve("AA:BB:CC:DD:EE:33")
|
||||||
|
|
||||||
|
# Verify sensor is no longer pending (filtered by config)
|
||||||
|
pending = dm.get_pending()
|
||||||
|
assert len(pending) == 0
|
||||||
|
|
||||||
|
# Verify it's in config
|
||||||
|
assert config.sensors["AA:BB:CC:DD:EE:33"] == "Kitchen Sensor"
|
||||||
Reference in New Issue
Block a user