feat: implement Textual TUI and SQLite database for sensor management

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

15
tests/conftest.py Normal file
View File

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

67
tests/test_config.py Normal file
View File

@@ -0,0 +1,67 @@
import os
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
import sensorpajen.config as config
def test_config_defaults():
assert config.MQTT_HOST == "localhost"
assert config.MQTT_PORT == 1883
assert config.MQTT_USER == "user"
assert config.MQTT_PASSWORD == "password"
assert config.MQTT_CLIENT_ID == "sensorpajen"
def test_sensor_config_load(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
sensors_data = {
"sensors": [
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room"},
{"mac": "11:22:33:44:55:66", "name": "Kitchen"}
]
}
import json
with open(config_file, "w") as f:
json.dump(sensors_data, f)
sensor_cfg = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg.sensors == {
"AA:BB:CC:DD:EE:FF": "Living Room",
"11:22:33:44:55:66": "Kitchen"
}
assert sensor_cfg.get_name("AA:BB:CC:DD:EE:FF") == "Living Room"
assert sensor_cfg.get_name("UNKNOWN") == "UNKNOWN"
def test_sensor_config_add_remove(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "sensors.json"
# Start with empty
with open(config_file, "w") as f:
import json
json.dump({"sensors": []}, f)
sensor_cfg = config.SensorConfig(config_file=str(config_file))
# Add
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "Test comment")
assert sensor_cfg.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
# Verify persistence
sensor_cfg2 = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg2.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
# Remove
sensor_cfg.remove_sensor("AA:BB:CC:DD:EE:FF")
assert "AA:BB:CC:DD:EE:FF" not in sensor_cfg.sensors
# Verify persistence
sensor_cfg3 = config.SensorConfig(config_file=str(config_file))
assert "AA:BB:CC:DD:EE:FF" not in sensor_cfg3.sensors
def test_sensor_config_missing_file(tmp_path):
import sensorpajen.config as config
config_file = tmp_path / "nonexistent.json"
sensor_cfg = config.SensorConfig(config_file=str(config_file))
assert sensor_cfg.sensors == {}

82
tests/test_db.py Normal file
View File

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

View File

@@ -0,0 +1,57 @@
import pytest
import os
from pathlib import Path
from sensorpajen.discovery_manager import DiscoveryManager, DiscoveredSensor
def test_discovery_manager_init(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
assert db_file.exists()
def test_discovery_manager_add_new(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
mac = "AA:BB:CC:DD:EE:FF"
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
pending = manager.get_pending()
assert len(pending) == 1
sensor = pending[0]
assert sensor.mac == mac
assert sensor.name == "ATC_123456"
assert sensor.sample_reading["temperature"] == 22.5
assert sensor.status == "pending"
assert sensor.count == 1
def test_discovery_manager_update_existing(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
mac = "AA:BB:CC:DD:EE:FF"
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
# Update with new values
manager.add_or_update(mac, "ATC_123456", -60, 23.0, 40, 99, 2.9)
pending = manager.get_pending()
assert len(pending) == 1
sensor = pending[0]
assert sensor.rssi == -60
assert sensor.sample_reading["temperature"] == 23.0
assert sensor.sample_reading["humidity"] == 40.0
assert sensor.count == 2
def test_discovery_manager_persistence(tmp_path):
db_file = tmp_path / "sensors.db"
manager = DiscoveryManager(str(db_file))
mac = "AA:BB:CC:DD:EE:FF"
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
# Create new manager and load from same DB
manager2 = DiscoveryManager(str(db_file))
pending = manager2.get_pending()
assert len(pending) == 1
assert pending[0].mac == mac
assert pending[0].name == "ATC_123456"

View File

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

View File

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

8
tests/test_tui.py Normal file
View File

@@ -0,0 +1,8 @@
import pytest
from sensorpajen.tui.app import SensorpajenApp
def test_tui_app_init():
# Just test that we can instantiate it
app = SensorpajenApp()
assert app.discovery_manager is not None
assert app.sensor_config is not None