Release v3.0.0

- Bump version to 3.0.0 and update docs

- Fix Debian payload to include TUI and install /usr/bin/sensorpajen-tui wrapper

- Make systemd unit upgrades safer and ignore deb build artifacts
This commit is contained in:
2025-12-29 15:34:03 +01:00
parent 54d55cf0f6
commit fcaaf29307
50 changed files with 963 additions and 2421 deletions

View File

@@ -34,6 +34,35 @@ def test_sensor_config_load(tmp_path):
assert sensor_cfg.get_name("AA:BB:CC:DD:EE:FF") == "Living Room"
assert sensor_cfg.get_name("UNKNOWN") == "UNKNOWN"
def test_sensor_config_comment_load_and_clear(tmp_path):
import json
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
config_file.write_text(
json.dumps(
{
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room", "comment": "hello"},
]
},
indent=2,
)
)
sensor_cfg = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") == "hello"
# Clear comment explicitly (empty string means remove comment key)
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "")
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") is None
saved = json.loads(config_file.read_text())
assert saved["sensors"][0]["mac"] == "AA:BB:CC:DD:EE:FF"
assert saved["sensors"][0]["name"] == "Living Room"
assert "comment" not in saved["sensors"][0]
def test_sensor_config_add_remove(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
@@ -47,10 +76,12 @@ def test_sensor_config_add_remove(tmp_path):
# Add
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "Test comment")
assert sensor_cfg.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
assert sensor_cfg.get_comment("AA:BB:CC:DD:EE:FF") == "Test comment"
# Verify persistence
sensor_cfg2 = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg2.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
assert sensor_cfg2.get_comment("AA:BB:CC:DD:EE:FF") == "Test comment"
# Remove
sensor_cfg.remove_sensor("AA:BB:CC:DD:EE:FF")

View File

@@ -3,6 +3,12 @@ import os
from pathlib import Path
from sensorpajen.discovery_manager import DiscoveryManager, DiscoveredSensor
class _DummyCompletedProcess:
def __init__(self, returncode: int = 0, stderr: bytes = b""):
self.returncode = returncode
self.stderr = stderr
def test_discovery_manager_init(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
@@ -55,3 +61,94 @@ def test_discovery_manager_persistence(tmp_path):
assert len(pending) == 1
assert pending[0].mac == mac
assert pending[0].name == "ATC_123456"
def test_send_ntfy_notification_disabled(monkeypatch, tmp_path):
from sensorpajen import discovery_manager as dm_mod
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", False)
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "token")
called = {"run": False}
def _fake_run(*args, **kwargs):
called["run"] = True
return _DummyCompletedProcess(0)
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
manager = dm_mod.DiscoveryManager(str(tmp_path / "dummy.db"))
sensor = dm_mod.DiscoveredSensor(
mac="AA",
name="N",
rssi=-1,
first_seen="now",
last_seen="now",
sample_reading={"temperature": 1, "humidity": 2, "battery_percent": 3},
)
manager.send_ntfy_notification(sensor)
assert called["run"] is False
def test_send_ntfy_notification_missing_token(monkeypatch, tmp_path):
from sensorpajen import discovery_manager as dm_mod
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", True)
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "")
called = {"run": False}
def _fake_run(*args, **kwargs):
called["run"] = True
return _DummyCompletedProcess(0)
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
manager = dm_mod.DiscoveryManager(str(tmp_path / "dummy2.db"))
sensor = dm_mod.DiscoveredSensor(
mac="AA",
name="N",
rssi=-1,
first_seen="now",
last_seen="now",
sample_reading={"temperature": 1, "humidity": 2, "battery_percent": 3},
)
manager.send_ntfy_notification(sensor)
assert called["run"] is False
def test_send_ntfy_notification_message_mentions_tui(monkeypatch, tmp_path):
from sensorpajen import discovery_manager as dm_mod
monkeypatch.setattr(dm_mod.config, "NTFY_ENABLED", True)
monkeypatch.setattr(dm_mod.config, "NTFY_TOKEN", "token")
monkeypatch.setattr(dm_mod.config, "NTFY_URL", "https://ntfy.sh")
monkeypatch.setattr(dm_mod.config, "NTFY_TOPIC", "sensorpajen")
captured = {"args": None}
def _fake_run(args, capture_output=True, timeout=10):
captured["args"] = args
return _DummyCompletedProcess(0)
monkeypatch.setattr(dm_mod.subprocess, "run", _fake_run)
manager = dm_mod.DiscoveryManager(str(tmp_path / "sensors.db"))
sensor = dm_mod.DiscoveredSensor(
mac="AA:BB",
name="ATC_123",
rssi=-1,
first_seen="2025-01-01T00:00:00",
last_seen="2025-01-01T00:00:00",
sample_reading={"temperature": 10, "humidity": 20, "battery_percent": 30},
)
manager.send_ntfy_notification(sensor)
assert captured["args"] is not None
# curl args: [..., "-d", message, url]
assert "-d" in captured["args"]
message = captured["args"][captured["args"].index("-d") + 1]
assert "sensorpajen-tui" in message

View File

@@ -1,8 +1,225 @@
import pytest
from sensorpajen.tui.app import SensorpajenApp
import tempfile
import json
import sqlite3
from pathlib import Path
from datetime import datetime
from sensorpajen.config import SensorConfig
from sensorpajen.discovery_manager import DiscoveryManager
def test_tui_app_init():
# Just test that we can instantiate it
app = SensorpajenApp()
assert app.discovery_manager is not None
assert app.sensor_config is not None
def test_tui_sensor_config_edit():
"""Integration test: Test that editing a sensor works end-to-end"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
db_file = Path(tmpdir) / "test.db"
# Create initial config
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room Sensor"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Initialize database
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovered_sensors (
mac TEXT PRIMARY KEY,
name TEXT,
rssi INTEGER,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
count INTEGER DEFAULT 0,
last_temp REAL,
last_humidity REAL,
last_battery_percent INTEGER,
last_battery_voltage INTEGER,
status TEXT DEFAULT 'pending',
reviewed BOOLEAN DEFAULT 0,
ignored_at TIMESTAMP,
ignore_reason TEXT
)
""")
now = datetime.now().isoformat()
cursor.execute("""
INSERT INTO discovered_sensors
(mac, name, rssi, first_seen, last_seen, count, last_temp, last_humidity,
last_battery_percent, last_battery_voltage, status, reviewed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', 1)
""", ("AA:BB:CC:DD:EE:FF", "Living Room Sensor", -65, now, now, 50, 23.5, 55, 85, 2950))
conn.commit()
conn.close()
# Load config and discovery manager (simulating TUI)
config = SensorConfig(str(config_file))
dm = DiscoveryManager(str(db_file), config)
# Verify initial state
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room Sensor"
# Edit sensor (simulate user action in TUI)
config.add_sensor("AA:BB:CC:DD:EE:FF", "Bedroom Sensor")
# Verify in-memory update
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Bedroom Sensor"
# Verify disk update
saved_data = json.loads(config_file.read_text())
assert saved_data["sensors"][0]["name"] == "Bedroom Sensor"
# Simulate refresh_data() - create new config instance and verify
config2 = SensorConfig(str(config_file))
assert config2.sensors["AA:BB:CC:DD:EE:FF"] == "Bedroom Sensor"
def test_sensor_config_edit_updates_memory():
"""Test that editing a sensor updates both disk and memory"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
# Create initial config
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Original Name"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Load config
config = SensorConfig(str(config_file))
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Original Name"
# Edit sensor
config.add_sensor("AA:BB:CC:DD:EE:FF", "Updated Name")
# Check in-memory is updated
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Updated Name"
# Check disk is updated
saved_data = json.loads(config_file.read_text())
assert saved_data["sensors"][0]["name"] == "Updated Name"
# Reload from disk and verify
config2 = SensorConfig(str(config_file))
assert config2.sensors["AA:BB:CC:DD:EE:FF"] == "Updated Name"
def test_sensor_config_remove_sensor():
"""Test that removing a sensor works correctly"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
# Create config with multiple sensors
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Sensor 1"},
{"mac": "AA:BB:CC:DD:EE:11", "name": "Sensor 2"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Load and verify
config = SensorConfig(str(config_file))
assert len(config.sensors) == 2
# Remove one sensor
config.remove_sensor("AA:BB:CC:DD:EE:FF")
# Check in-memory removal
assert "AA:BB:CC:DD:EE:FF" not in config.sensors
assert "AA:BB:CC:DD:EE:11" in config.sensors
# Check disk update
saved_data = json.loads(config_file.read_text())
assert len(saved_data["sensors"]) == 1
assert saved_data["sensors"][0]["mac"] == "AA:BB:CC:DD:EE:11"
def test_sensor_config_reload():
"""Test that reload() re-reads from disk"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
# Create initial config
initial_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Original Name"}
]
}
config_file.write_text(json.dumps(initial_data, indent=2))
# Load config
config = SensorConfig(str(config_file))
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Original Name"
# Manually modify file on disk
new_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Externally Modified"}
]
}
config_file.write_text(json.dumps(new_data, indent=2))
# Reload should pick up the changes
config.load()
assert config.sensors["AA:BB:CC:DD:EE:FF"] == "Externally Modified"
def test_discovery_manager_approve_sensor():
"""Test that approving a sensor works correctly"""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "sensors.json"
db_file = Path(tmpdir) / "test.db"
# Create empty config
config_file.write_text(json.dumps({"sensors": []}, indent=2))
# Initialize database with pending sensor
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovered_sensors (
mac TEXT PRIMARY KEY,
name TEXT,
rssi INTEGER,
first_seen TIMESTAMP,
last_seen TIMESTAMP,
count INTEGER DEFAULT 0,
last_temp REAL,
last_humidity REAL,
last_battery_percent INTEGER,
last_battery_voltage INTEGER,
status TEXT DEFAULT 'pending',
reviewed BOOLEAN DEFAULT 0,
ignored_at TIMESTAMP,
ignore_reason TEXT
)
""")
now = datetime.now().isoformat()
cursor.execute("""
INSERT INTO discovered_sensors
(mac, name, rssi, first_seen, last_seen, count, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", ("AA:BB:CC:DD:EE:33", "Unknown Sensor", -80, now, now, 1, "pending"))
conn.commit()
conn.close()
# Load config and DM
config = SensorConfig(str(config_file))
dm = DiscoveryManager(str(db_file), config)
# Verify sensor is pending
pending = dm.get_pending()
assert len(pending) == 1
assert pending[0].mac == "AA:BB:CC:DD:EE:33"
# Approve and add to config (simulate TUI action)
config.add_sensor("AA:BB:CC:DD:EE:33", "Kitchen Sensor")
dm.approve("AA:BB:CC:DD:EE:33")
# Verify sensor is no longer pending (filtered by config)
pending = dm.get_pending()
assert len(pending) == 0
# Verify it's in config
assert config.sensors["AA:BB:CC:DD:EE:33"] == "Kitchen Sensor"