feat: implement Textual TUI and SQLite database for sensor management
This commit is contained in:
15
tests/conftest.py
Normal file
15
tests/conftest.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock environment variables required by config.py
|
||||
os.environ["MQTT_HOST"] = "localhost"
|
||||
os.environ["MQTT_PORT"] = "1883"
|
||||
os.environ["MQTT_USER"] = "user"
|
||||
os.environ["MQTT_PASSWORD"] = "password"
|
||||
|
||||
# Mock bluetooth package globally for all tests
|
||||
mock_bluetooth = MagicMock()
|
||||
mock_bluez = MagicMock()
|
||||
sys.modules["bluetooth"] = mock_bluetooth
|
||||
sys.modules["bluetooth._bluetooth"] = mock_bluez
|
||||
67
tests/test_config.py
Normal file
67
tests/test_config.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
import sensorpajen.config as config
|
||||
|
||||
def test_config_defaults():
|
||||
assert config.MQTT_HOST == "localhost"
|
||||
assert config.MQTT_PORT == 1883
|
||||
assert config.MQTT_USER == "user"
|
||||
assert config.MQTT_PASSWORD == "password"
|
||||
assert config.MQTT_CLIENT_ID == "sensorpajen"
|
||||
|
||||
def test_sensor_config_load(tmp_path):
|
||||
import sensorpajen.config as config
|
||||
|
||||
config_file = tmp_path / "sensors.json"
|
||||
sensors_data = {
|
||||
"sensors": [
|
||||
{"mac": "AA:BB:CC:DD:EE:FF", "name": "Living Room"},
|
||||
{"mac": "11:22:33:44:55:66", "name": "Kitchen"}
|
||||
]
|
||||
}
|
||||
|
||||
import json
|
||||
with open(config_file, "w") as f:
|
||||
json.dump(sensors_data, f)
|
||||
|
||||
sensor_cfg = config.SensorConfig(config_file=str(config_file))
|
||||
assert sensor_cfg.sensors == {
|
||||
"AA:BB:CC:DD:EE:FF": "Living Room",
|
||||
"11:22:33:44:55:66": "Kitchen"
|
||||
}
|
||||
assert sensor_cfg.get_name("AA:BB:CC:DD:EE:FF") == "Living Room"
|
||||
assert sensor_cfg.get_name("UNKNOWN") == "UNKNOWN"
|
||||
|
||||
def test_sensor_config_add_remove(tmp_path):
|
||||
import sensorpajen.config as config
|
||||
config_file = tmp_path / "sensors.json"
|
||||
# Start with empty
|
||||
with open(config_file, "w") as f:
|
||||
import json
|
||||
json.dump({"sensors": []}, f)
|
||||
|
||||
sensor_cfg = config.SensorConfig(config_file=str(config_file))
|
||||
|
||||
# Add
|
||||
sensor_cfg.add_sensor("AA:BB:CC:DD:EE:FF", "Living Room", "Test comment")
|
||||
assert sensor_cfg.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
|
||||
|
||||
# Verify persistence
|
||||
sensor_cfg2 = config.SensorConfig(config_file=str(config_file))
|
||||
assert sensor_cfg2.sensors["AA:BB:CC:DD:EE:FF"] == "Living Room"
|
||||
|
||||
# Remove
|
||||
sensor_cfg.remove_sensor("AA:BB:CC:DD:EE:FF")
|
||||
assert "AA:BB:CC:DD:EE:FF" not in sensor_cfg.sensors
|
||||
|
||||
# Verify persistence
|
||||
sensor_cfg3 = config.SensorConfig(config_file=str(config_file))
|
||||
assert "AA:BB:CC:DD:EE:FF" not in sensor_cfg3.sensors
|
||||
|
||||
def test_sensor_config_missing_file(tmp_path):
|
||||
import sensorpajen.config as config
|
||||
config_file = tmp_path / "nonexistent.json"
|
||||
sensor_cfg = config.SensorConfig(config_file=str(config_file))
|
||||
assert sensor_cfg.sensors == {}
|
||||
82
tests/test_db.py
Normal file
82
tests/test_db.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import pytest
|
||||
import sqlite3
|
||||
import os
|
||||
from pathlib import Path
|
||||
from sensorpajen.db import DatabaseManager
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
return tmp_path / "test_sensors.db"
|
||||
|
||||
@pytest.fixture
|
||||
def db_manager(db_path):
|
||||
manager = DatabaseManager(str(db_path))
|
||||
manager.initialize()
|
||||
return manager
|
||||
|
||||
def test_db_initialization(db_path):
|
||||
manager = DatabaseManager(str(db_path))
|
||||
manager.initialize()
|
||||
assert db_path.exists()
|
||||
|
||||
# Verify table exists
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='discovered_sensors'")
|
||||
assert cursor.fetchone() is not None
|
||||
conn.close()
|
||||
|
||||
def test_add_or_update_sensor(db_manager):
|
||||
mac = "AA:BB:CC:DD:EE:FF"
|
||||
db_manager.add_or_update_sensor(
|
||||
mac=mac,
|
||||
name="ATC_123456",
|
||||
rssi=-70,
|
||||
temp=22.5,
|
||||
humidity=45.0,
|
||||
battery_percent=100,
|
||||
battery_voltage=3000
|
||||
)
|
||||
|
||||
sensor = db_manager.get_sensor(mac)
|
||||
assert sensor["mac"] == mac
|
||||
assert sensor["name"] == "ATC_123456"
|
||||
assert sensor["count"] == 1
|
||||
assert sensor["status"] == "pending"
|
||||
|
||||
# Update
|
||||
db_manager.add_or_update_sensor(
|
||||
mac=mac,
|
||||
name="ATC_123456",
|
||||
rssi=-60,
|
||||
temp=23.0,
|
||||
humidity=40.0,
|
||||
battery_percent=99,
|
||||
battery_voltage=2900
|
||||
)
|
||||
|
||||
sensor = db_manager.get_sensor(mac)
|
||||
assert sensor["count"] == 2
|
||||
assert sensor["rssi"] == -60
|
||||
assert sensor["last_temp"] == 23.0
|
||||
|
||||
def test_update_status(db_manager):
|
||||
mac = "AA:BB:CC:DD:EE:FF"
|
||||
db_manager.add_or_update_sensor(mac, "Test", -70, 20, 50, 100, 3000)
|
||||
|
||||
db_manager.update_status(mac, "approved")
|
||||
sensor = db_manager.get_sensor(mac)
|
||||
assert sensor["status"] == "approved"
|
||||
|
||||
def test_get_sensors_by_status(db_manager):
|
||||
db_manager.add_or_update_sensor("MAC1", "S1", -70, 20, 50, 100, 3000)
|
||||
db_manager.add_or_update_sensor("MAC2", "S2", -70, 20, 50, 100, 3000)
|
||||
db_manager.update_status("MAC2", "ignored")
|
||||
|
||||
pending = db_manager.get_sensors(status="pending")
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["mac"] == "MAC1"
|
||||
|
||||
ignored = db_manager.get_sensors(status="ignored")
|
||||
assert len(ignored) == 1
|
||||
assert ignored[0]["mac"] == "MAC2"
|
||||
57
tests/test_discovery_manager.py
Normal file
57
tests/test_discovery_manager.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from sensorpajen.discovery_manager import DiscoveryManager, DiscoveredSensor
|
||||
|
||||
def test_discovery_manager_init(tmp_path):
|
||||
db_file = tmp_path / "sensors.db"
|
||||
manager = DiscoveryManager(str(db_file))
|
||||
assert db_file.exists()
|
||||
|
||||
def test_discovery_manager_add_new(tmp_path):
|
||||
db_file = tmp_path / "sensors.db"
|
||||
manager = DiscoveryManager(str(db_file))
|
||||
|
||||
mac = "AA:BB:CC:DD:EE:FF"
|
||||
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
|
||||
|
||||
pending = manager.get_pending()
|
||||
assert len(pending) == 1
|
||||
sensor = pending[0]
|
||||
assert sensor.mac == mac
|
||||
assert sensor.name == "ATC_123456"
|
||||
assert sensor.sample_reading["temperature"] == 22.5
|
||||
assert sensor.status == "pending"
|
||||
assert sensor.count == 1
|
||||
|
||||
def test_discovery_manager_update_existing(tmp_path):
|
||||
db_file = tmp_path / "sensors.db"
|
||||
manager = DiscoveryManager(str(db_file))
|
||||
|
||||
mac = "AA:BB:CC:DD:EE:FF"
|
||||
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
|
||||
|
||||
# Update with new values
|
||||
manager.add_or_update(mac, "ATC_123456", -60, 23.0, 40, 99, 2.9)
|
||||
|
||||
pending = manager.get_pending()
|
||||
assert len(pending) == 1
|
||||
sensor = pending[0]
|
||||
assert sensor.rssi == -60
|
||||
assert sensor.sample_reading["temperature"] == 23.0
|
||||
assert sensor.sample_reading["humidity"] == 40.0
|
||||
assert sensor.count == 2
|
||||
|
||||
def test_discovery_manager_persistence(tmp_path):
|
||||
db_file = tmp_path / "sensors.db"
|
||||
manager = DiscoveryManager(str(db_file))
|
||||
|
||||
mac = "AA:BB:CC:DD:EE:FF"
|
||||
manager.add_or_update(mac, "ATC_123456", -70, 22.5, 45, 100, 3.0)
|
||||
|
||||
# Create new manager and load from same DB
|
||||
manager2 = DiscoveryManager(str(db_file))
|
||||
pending = manager2.get_pending()
|
||||
assert len(pending) == 1
|
||||
assert pending[0].mac == mac
|
||||
assert pending[0].name == "ATC_123456"
|
||||
76
tests/test_mqtt_publisher.py
Normal file
76
tests/test_mqtt_publisher.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
from sensorpajen.mqtt_publisher import MQTTPublisher
|
||||
import sensorpajen.config as config
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
with patch("sensorpajen.config.MQTT_HOST", "localhost"), \
|
||||
patch("sensorpajen.config.MQTT_PORT", 1883), \
|
||||
patch("sensorpajen.config.MQTT_USER", "user"), \
|
||||
patch("sensorpajen.config.MQTT_PASSWORD", "pass"), \
|
||||
patch("sensorpajen.config.MQTT_CLIENT_ID", "test_client"), \
|
||||
patch("sensorpajen.config.MQTT_TOPIC_PREFIX", "test"):
|
||||
yield
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
with patch("sensorpajen.config.MQTT_HOST", "localhost"), \
|
||||
patch("sensorpajen.config.MQTT_PORT", 1883), \
|
||||
patch("sensorpajen.config.MQTT_USER", "user"), \
|
||||
patch("sensorpajen.config.MQTT_PASSWORD", "pass"), \
|
||||
patch("sensorpajen.config.MQTT_CLIENT_ID", "test_client"), \
|
||||
patch("sensorpajen.config.MQTT_TOPIC_PREFIX", "test"):
|
||||
yield
|
||||
|
||||
def test_mqtt_publisher_init(mock_config):
|
||||
with patch("paho.mqtt.client.Client") as mock_client:
|
||||
publisher = MQTTPublisher()
|
||||
mock_client.assert_called_once()
|
||||
publisher.client.username_pw_set.assert_called_with("user", "pass")
|
||||
|
||||
def test_mqtt_publisher_connect(mock_config):
|
||||
with patch("paho.mqtt.client.Client") as mock_client:
|
||||
publisher = MQTTPublisher()
|
||||
publisher.connect()
|
||||
publisher.client.connect.assert_called_with("localhost", 1883, keepalive=60)
|
||||
publisher.client.loop_start.assert_called_once()
|
||||
|
||||
def test_mqtt_publisher_publish(mock_config):
|
||||
with patch("paho.mqtt.client.Client") as mock_client:
|
||||
publisher = MQTTPublisher()
|
||||
publisher.connected = True
|
||||
|
||||
with patch("sensorpajen.config.ENABLE_BATTERY", True):
|
||||
publisher.publish_measurement("living_room", 22.5, 45, 3.0, 100)
|
||||
|
||||
# Check if publish was called for each metric
|
||||
calls = [
|
||||
call("test/living_room/temp", "22.5"),
|
||||
call("test/living_room/humidity", "45"),
|
||||
call("test/living_room/batteryvoltage", "3.000"),
|
||||
call("test/living_room/batterylevel", "100")
|
||||
]
|
||||
publisher.client.publish.assert_has_calls(calls, any_order=True)
|
||||
|
||||
def test_mqtt_publisher_publish_no_battery(mock_config):
|
||||
with patch("paho.mqtt.client.Client") as mock_client:
|
||||
publisher = MQTTPublisher()
|
||||
publisher.connected = True
|
||||
|
||||
with patch("sensorpajen.config.ENABLE_BATTERY", False):
|
||||
publisher.publish_measurement("living_room", 22.5, 45, 3.0, 100)
|
||||
|
||||
# Should only publish temp and humidity
|
||||
assert publisher.client.publish.call_count == 2
|
||||
publisher.client.publish.assert_any_call("test/living_room/temp", "22.5")
|
||||
publisher.client.publish.assert_any_call("test/living_room/humidity", "45")
|
||||
|
||||
def test_mqtt_publisher_not_connected(mock_config):
|
||||
with patch("paho.mqtt.client.Client") as mock_client:
|
||||
publisher = MQTTPublisher()
|
||||
publisher.connected = False
|
||||
|
||||
# Should not raise error, just log warning
|
||||
publisher.publish_measurement("living_room", 22.5, 45)
|
||||
publisher.client.publish.assert_not_called()
|
||||
98
tests/test_sensor_reader.py
Normal file
98
tests/test_sensor_reader.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from sensorpajen.sensor_reader import SensorReader, Measurement
|
||||
from sensorpajen import config
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sensor_config():
|
||||
cfg = MagicMock(spec=config.SensorConfig)
|
||||
cfg.sensors = {"AA:BB:CC:DD:EE:FF": "Living Room"}
|
||||
cfg.get_name.side_effect = lambda mac: cfg.sensors.get(mac.upper(), mac.upper())
|
||||
return cfg
|
||||
|
||||
@pytest.fixture
|
||||
def mock_discovery_manager():
|
||||
return MagicMock()
|
||||
|
||||
def test_measurement_dataclass():
|
||||
m = Measurement(temperature=22.5, humidity=45, voltage=3.0, battery=100, sensor_name="Test")
|
||||
assert m.temperature == 22.5
|
||||
assert m.humidity == 45
|
||||
assert m.voltage == 3.0
|
||||
assert m.battery == 100
|
||||
assert m.sensor_name == "Test"
|
||||
|
||||
def test_sensor_reader_init(mock_sensor_config, mock_discovery_manager):
|
||||
on_measurement = MagicMock()
|
||||
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
|
||||
assert reader.sensor_config == mock_sensor_config
|
||||
assert reader.on_measurement == on_measurement
|
||||
assert reader.interface == 0
|
||||
|
||||
@patch("sensorpajen.sensor_reader.toggle_device")
|
||||
@patch("sensorpajen.sensor_reader.enable_le_scan")
|
||||
@patch("sensorpajen.sensor_reader.parse_le_advertising_events")
|
||||
def test_sensor_reader_start(mock_parse, mock_enable, mock_toggle, mock_sensor_config, mock_discovery_manager):
|
||||
on_measurement = MagicMock()
|
||||
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
|
||||
|
||||
# Mock bluez.hci_open_dev where it's used in sensor_reader
|
||||
with patch("sensorpajen.sensor_reader.bluez.hci_open_dev", return_value=123):
|
||||
# We need to stop the blocking call to parse_le_advertising_events
|
||||
mock_parse.side_effect = KeyboardInterrupt()
|
||||
|
||||
reader.start()
|
||||
|
||||
mock_toggle.assert_called_with(0, True)
|
||||
mock_enable.assert_called_with(123, filter_duplicates=False)
|
||||
mock_parse.assert_called_once()
|
||||
|
||||
@patch("sensorpajen.sensor_reader.raw_packet_to_str")
|
||||
def test_handle_ble_packet_known_sensor(mock_raw_to_str, mock_sensor_config, mock_discovery_manager):
|
||||
on_measurement = MagicMock()
|
||||
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
|
||||
|
||||
# Mock data
|
||||
mac = "AA:BB:CC:DD:EE:FF"
|
||||
data = b"\x00" * 20
|
||||
# ATC packet format: ... 1A18 AABBCCDDEEFF ...
|
||||
# data_str[6:10] == "1A18"
|
||||
# data_str[10:22] == "AABBCCDDEEFF"
|
||||
mock_raw_to_str.return_value = "0000001A18AABBCCDDEEFF000000000000"
|
||||
|
||||
# Mock _parse_atc_data
|
||||
with patch.object(reader, "_parse_atc_data") as mock_parse_atc:
|
||||
mock_parse_atc.return_value = (22.5, 45, 100, 3.0, "123")
|
||||
|
||||
reader._handle_ble_packet(mac, 0, data, -70)
|
||||
|
||||
on_measurement.assert_called_once()
|
||||
measurement = on_measurement.call_args[0][0]
|
||||
assert measurement.temperature == 22.5
|
||||
assert measurement.humidity == 45
|
||||
assert measurement.sensor_name == "Living Room"
|
||||
|
||||
@patch("sensorpajen.sensor_reader.raw_packet_to_str")
|
||||
def test_handle_ble_packet_unknown_sensor(mock_raw_to_str, mock_sensor_config, mock_discovery_manager):
|
||||
on_measurement = MagicMock()
|
||||
reader = SensorReader(mock_sensor_config, mock_discovery_manager, on_measurement)
|
||||
|
||||
# Mock data for unknown sensor
|
||||
mac = "11:22:33:44:55:66"
|
||||
data = b"\x00" * 20
|
||||
mock_raw_to_str.return_value = "0000001A18112233445566000000000000"
|
||||
|
||||
with patch.object(reader, "_parse_atc_data") as mock_parse_atc:
|
||||
mock_parse_atc.return_value = (20.0, 50, 80, 2.8, "456")
|
||||
|
||||
with patch.object(reader, "_handle_unknown_sensor") as mock_handle_unknown:
|
||||
reader._handle_ble_packet(mac, 0, data, -80)
|
||||
|
||||
mock_handle_unknown.assert_called_once_with(
|
||||
"11:22:33:44:55:66", -80, 20.0, 50, 80, 2.8
|
||||
)
|
||||
on_measurement.assert_not_called()
|
||||
8
tests/test_tui.py
Normal file
8
tests/test_tui.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
from sensorpajen.tui.app import SensorpajenApp
|
||||
|
||||
def test_tui_app_init():
|
||||
# Just test that we can instantiate it
|
||||
app = SensorpajenApp()
|
||||
assert app.discovery_manager is not None
|
||||
assert app.sensor_config is not None
|
||||
Reference in New Issue
Block a user