Files

363 lines
12 KiB
Python
Raw Permalink Normal View History

2026-08-22 16:12:14 +02:00
import unittest
2026-08-22 16:33:34 +02:00
from pathlib import Path
from tempfile import TemporaryDirectory
2026-08-22 16:12:14 +02:00
from unittest.mock import patch
2026-08-22 19:54:38 +02:00
from lib._zyxel import ZyxelRouter, normalize_mac
2026-08-23 08:16:15 +02:00
from lib._arrival_detection import (
ArrivalDetector,
ArrivalMonitor,
_configured_devices,
_record_arrival,
)
from lib._notify import accepts_event
from service import Controller
2026-08-22 16:12:14 +02:00
class ArrivalDetectorTests(unittest.TestCase):
def test_starting_present_does_not_emit_arrival(self):
detector = ArrivalDetector(absent_after=2, present_after=2)
self.assertFalse(detector.sample(True))
self.assertFalse(detector.sample(True))
def test_emits_only_after_absence_and_debounced_presence(self):
detector = ArrivalDetector(absent_after=2, present_after=2)
for value in (False, False, True):
self.assertFalse(detector.sample(value))
self.assertTrue(detector.sample(True))
self.assertFalse(detector.sample(True))
def test_single_missed_poll_does_not_mark_absent(self):
detector = ArrivalDetector(absent_after=2, present_after=1)
detector.sample(True)
self.assertFalse(detector.sample(False))
self.assertFalse(detector.sample(True))
2026-08-23 08:16:15 +02:00
def test_accepts_up_to_three_devices(self):
devices = _configured_devices(
{
"devices": [
{"id": "one", "mac": "00:00:00:00:00:01"},
{"id": "two", "mac": "00:00:00:00:00:02"},
{"id": "three", "mac": "00:00:00:00:00:03"},
]
}
)
self.assertEqual(len(devices), 3)
def test_rejects_more_than_three_devices(self):
config = {
"devices": [
{"id": str(index), "mac": f"00:00:00:00:00:0{index}"}
for index in range(1, 5)
]
}
with self.assertRaisesRegex(ValueError, "at most 3"):
_configured_devices(config)
@patch("lib._arrival_detection._build_router")
def test_monitor_emits_generic_arrived_event(self, _build_router):
events = []
monitor = ArrivalMonitor(
{
"devices": [
{"id": "ignace", "mac": "00:00:00:00:00:01"}
],
"polling": {"absent_after": 1, "present_after": 1},
},
events.append,
)
mac = "00:00:00:00:00:01"
monitor._process_states({mac: False})
monitor._process_states({mac: True})
self.assertEqual(
events,
[
{
"sender": "_arrival_detection",
"event": "arrived",
"id": "ignace",
"text": "",
2026-08-26 18:17:03 +02:00
},
{
"sender": "_arrival_detection",
"event": "first_arrival",
"id": "ignace",
"text": "",
},
],
)
@patch("lib._arrival_detection._build_router")
def test_first_arrival_fires_only_when_empty_house_becomes_occupied(
self, _build_router
):
events = []
monitor = ArrivalMonitor(
{
"devices": [
{"id": "ignace", "mac": "00:00:00:00:00:01"},
{"id": "janine", "mac": "00:00:00:00:00:02"},
],
"polling": {"absent_after": 1, "present_after": 1},
},
events.append,
)
first = "00:00:00:00:00:01"
second = "00:00:00:00:00:02"
monitor._process_states({first: False, second: False})
monitor._process_states({first: True, second: False})
monitor._process_states({first: True, second: True})
self.assertEqual(
[(event["event"], event["id"]) for event in events],
[
("arrived", "ignace"),
("first_arrival", "ignace"),
("arrived", "janine"),
2026-08-23 08:16:15 +02:00
],
)
@patch("lib._arrival_detection._build_router")
def test_monitor_emits_departure_and_empty_after_last_device_leaves(self, _build_router):
events = []
monitor = ArrivalMonitor(
{
"devices": [
{"id": "ignace", "mac": "00:00:00:00:00:01"},
{"id": "janine", "mac": "00:00:00:00:00:02"},
],
"polling": {"absent_after": 1, "present_after": 1},
},
events.append,
)
first = "00:00:00:00:00:01"
second = "00:00:00:00:00:02"
monitor._process_states({first: True, second: True})
self.assertEqual(events, [])
monitor._process_states({first: False, second: True})
self.assertEqual([event["event"] for event in events], ["departed"])
monitor._process_states({first: False, second: False})
self.assertEqual(
[(event["event"], event["id"]) for event in events],
[("departed", "ignace"), ("departed", "janine"), ("empty", "house")],
)
@patch("lib._arrival_detection._build_router")
def test_initially_empty_house_emits_no_events(self, _build_router):
events = []
monitor = ArrivalMonitor(
{
"devices": [{"id": "ignace", "mac": "00:00:00:00:00:01"}],
"polling": {"absent_after": 1, "present_after": 1},
},
events.append,
)
monitor._process_states({"00:00:00:00:00:01": False})
self.assertEqual(events, [])
2026-08-22 16:12:14 +02:00
class MacTests(unittest.TestCase):
def test_normalizes_common_formats(self):
self.assertEqual(normalize_mac("AA-BB-CC-DD-EE-FF"), "aa:bb:cc:dd:ee:ff")
def test_rejects_invalid_mac(self):
with self.assertRaises(ValueError):
normalize_mac("not-a-mac")
2026-08-22 19:54:38 +02:00
def test_parses_ex5601_nested_lanhosts_response(self):
router = ZyxelRouter("192.0.2.1", "user", "password")
router.dal_get = lambda _oid: {
"result": "ZCFG_SUCCESS",
"Object": [
{
"lanhosts": [
{
"PhysAddress": "2C:DB:07:50:2B:5C",
"Active": True,
"HostName": "workstation",
"IPAddress": "192.168.178.10",
}
]
}
],
}
hosts = router.get_lan_hosts()
self.assertEqual(len(hosts), 1)
self.assertEqual(hosts[0].mac, "2c:db:07:50:2b:5c")
self.assertTrue(router.is_connected("2c-db-07-50-2b-5c"))
2026-08-23 08:16:15 +02:00
def test_logs_only_the_first_successful_zyxel_connection(self):
router = ZyxelRouter("192.0.2.1", "user", "password")
with self.assertLogs("home_control.zyxel", level="INFO") as logs:
router._log_first_connection()
router._log_first_connection()
self.assertEqual(len(logs.output), 1)
self.assertIn("Connected successfully", logs.output[0])
def test_debug_logs_presence_result(self):
router = ZyxelRouter("192.0.2.1", "user", "password", debug=True)
router.get_lan_hosts = lambda: []
with self.assertLogs("home_control.zyxel", level="INFO") as logs:
self.assertFalse(router.is_connected("2c:db:07:50:2b:5c"))
self.assertIn("2c:db:07:50:2b:5c -> absent", logs.output[0])
2026-08-22 16:12:14 +02:00
class NotificationTests(unittest.TestCase):
2026-08-23 08:16:15 +02:00
@patch("lib._cloud_logger.send_event")
@patch("lib._notify.send_notification")
def test_controller_routes_event_to_multiple_actions(
self, send_notification, send_event
):
config = {
"controller": {
"plugins": {
"source": {
"module": "lib.example",
"config": "source",
"on_event": ["_notify", "_cloud_logger"],
}
},
"actions": {
"_notify": {
"module": "lib._notify",
"config": "notify",
},
"_cloud_logger": {
"module": "lib._cloud_logger",
"config": "cloud_logger",
},
},
},
"source": {},
"notify": {"topic_url": "https://ntfy.example/home"},
"cloud_logger": {"event_url": "https://logger.example/event"},
}
message = {
"sender": "source",
"event": "arrived",
"id": "ignace",
"text": "hello",
}
Controller(config).handle_event(message)
send_notification.assert_called_once()
send_event.assert_called_once()
payload = send_event.call_args.args[0]
self.assertEqual(payload["sender"], "source")
self.assertEqual(payload["event"], "arrived")
self.assertEqual(payload["id"], "ignace")
self.assertEqual(payload["text"], "hello")
self.assertIn("datetime", payload)
self.assertEqual(
send_event.call_args.kwargs,
{"event_url": "https://logger.example/event", "timeout": 10.0},
)
@patch("lib._notify.send_notification")
def test_controller_routes_generic_event_to_notify(self, send_notification):
2026-08-22 16:12:14 +02:00
config = {
2026-08-23 08:16:15 +02:00
"controller": {
"plugins": {
"_arrival_detection": {
"module": "lib._arrival_detection",
"config": "arrival_detection",
"on_event": "_notify",
}
},
"actions": {
"_notify": {
"module": "lib._notify",
"handler": "on_event",
"config": "notify",
}
},
},
"arrival_detection": {},
"notify": {
2026-08-22 16:12:14 +02:00
"enabled": True,
2026-08-23 08:16:15 +02:00
"message": "Welcome home, {id}",
2026-08-22 16:12:14 +02:00
"topic_url": "https://ntfy.example/home",
"timeout": 4,
2026-08-23 08:16:15 +02:00
},
2026-08-22 16:12:14 +02:00
}
2026-08-23 08:16:15 +02:00
Controller(config).handle_event(
{
"sender": "_arrival_detection",
"event": "arrived",
"id": "ignace",
"text": "",
}
)
2026-08-22 16:12:14 +02:00
send_notification.assert_called_once_with(
2026-08-23 08:16:15 +02:00
"Welcome home, ignace",
2026-08-22 16:12:14 +02:00
topic_url="https://ntfy.example/home",
timeout=4.0,
)
2026-08-23 08:16:15 +02:00
def test_controller_has_no_plugin_specific_setup(self):
config = {
"controller": {
"plugins": {
"example": {
"module": "lib.example",
"factory": "build",
"config": "example_settings",
"on_event": "example_action",
}
},
"actions": {},
},
"example_settings": {},
}
controller = Controller(config)
self.assertEqual(controller.plugin_specs["example"]["factory"], "build")
def test_notify_accept_filter(self):
config = {
"filter": {
"accept": {"events": ["arrived"], "ids": ["ignace"]},
"ignore": {"events": [], "ids": []},
}
}
self.assertTrue(
accepts_event(config, {"event": "arrived", "id": "ignace"})
)
self.assertFalse(
accepts_event(config, {"event": "departed", "id": "ignace"})
)
self.assertFalse(
accepts_event(config, {"event": "arrived", "id": "janine"})
)
def test_notify_ignore_filter_takes_precedence(self):
config = {
"filter": {
"accept": {"events": ["departed"], "ids": ["ignace"]},
"ignore": {"events": ["departed"], "ids": []},
}
}
self.assertFalse(
accepts_event(config, {"event": "departed", "id": "ignace"})
)
def test_monitor_appends_arrival_to_event_log(self):
2026-08-22 16:33:34 +02:00
with TemporaryDirectory() as directory:
event_log = Path(directory) / "arrivals.txt"
config = {
2026-08-23 08:16:15 +02:00
"event_log": str(event_log),
2026-08-22 16:33:34 +02:00
}
2026-08-23 08:16:15 +02:00
_record_arrival(
config, {"id": "ignace", "mac": "aa:bb:cc:dd:ee:ff"}
)
self.assertTrue(
event_log.read_text().endswith(
" ARRIVED id=ignace mac=aa:bb:cc:dd:ee:ff\n"
)
)
2026-08-22 16:33:34 +02:00
2026-08-22 16:12:14 +02:00
if __name__ == "__main__":
unittest.main()