included cloud logging with filters

This commit is contained in:
2026-08-23 08:16:15 +02:00
parent d43b375d73
commit ce8e20784a
13 changed files with 955 additions and 208 deletions
+30 -4
View File
@@ -15,13 +15,39 @@ Check any MAC address directly through the Zyxel client with:
python lib/_zyxel.py --mac AA:BB:CC:DD:EE:FF python lib/_zyxel.py --mac AA:BB:CC:DD:EE:FF
``` ```
Omit `--mac` to check `arrival_detection.device.mac` from `service.yaml`. Omit `--mac` to check the first entry in `arrival_detection.devices` from
`service.yaml`. Arrival detection supports one to three devices and tracks each
device independently.
Set `arrival_detection.zyxel.debug` to `true` to log every Zyxel HTTP/DAL call
and MAC presence result. Under systemd, view these messages with
`journalctl -u home-control.service -f`.
An arrival is logged after the phone was confirmed absent and then present. It An arrival is logged after the phone was confirmed absent and then present. It
also calls `send_notification()` from `notify.py`; its message, ntfy topic, and also calls `send_notification()` from `lib/_notify.py`; its message, ntfy topic,
timeout are configured under `arrival_detection.notification`. Set and timeout are configured under the top-level `notify` section. Set
`arrival_detection.enabled` to `false` to disable this function. `arrival_detection.enabled` to `false` to disable this function.
Plugins emit dictionaries containing `sender`, `event`, `id`, and `text`. The
controller routes each sender to one or more actions through `controller.plugins`.
For example, `_arrival_detection` emits an `arrived` event and can be routed to
both actions through `on_event: [_notify, _cloud_logger]`. A single action name
such as `on_event: _notify` remains supported.
Arrival detection emits `arrived` and `departed` for individual device IDs. It
also emits `empty` with ID `house` after the last present device departs. Initial
startup state never generates these transition events.
Plugin modules, factories, configuration sections, and event actions are all
declared under `controller`. The controller contains no plugin-specific imports
or startup logic. A plugin module exposes a configured factory (normally
`create`), and an action module exposes a configured handler (normally
`on_event`).
The `_notify` action can filter by event or ID under `notify.filter`. Empty
`accept` lists allow all values; populated lists act as allow-lists. Values in
`ignore` are always rejected, even when also accepted.
On the iPhone, open **Settings > Wi-Fi**, tap the info button beside the home On the iPhone, open **Settings > Wi-Fi**, tap the info button beside the home
network, and copy **Wi-Fi Address**. If Private Wi-Fi Address is enabled, that network, and copy **Wi-Fi Address**. If Private Wi-Fi Address is enabled, that
per-network address is the correct one to configure. per-network address is the correct one to configure.
@@ -50,4 +76,4 @@ sudo journalctl -u home-control.service -f
Confirmed arrival notifications are also appended to Confirmed arrival notifications are also appended to
`/log/detect-arrivals.txt`. The path is configurable as `/log/detect-arrivals.txt`. The path is configurable as
`arrival_detection.notification.event_log`. `arrival_detection.event_log`.
+4
View File
@@ -0,0 +1,4 @@
HOME_CONTROL_LOG: flask_home_control_log.log
ip_address: 127.0.0.1
port: 5000
url_prefix: /home_logger
+2
View File
@@ -0,0 +1,2 @@
{"timestamp":"2026-08-23T06:09:35.779477+00:00","remote_addr":"127.0.0.1","event":{"sender":"_arrival_detection","event":"departed","id":"ignace","text":""}}
{"timestamp":"2026-08-23T06:10:47.665139+00:00","remote_addr":"127.0.0.1","event":{"sender":"_arrival_detection","event":"arrived","id":"ignace","text":""}}
+70
View File
@@ -0,0 +1,70 @@
import json
import os
from datetime import datetime, timezone
from pathlib import Path
import yaml
from flask import Flask, Response, jsonify, request
CONFIG_FILE = Path(__file__).with_name("config.yaml")
with CONFIG_FILE.open(encoding="utf-8") as stream:
CONFIG = yaml.safe_load(stream) or {}
LOG_FILE = Path(CONFIG["HOME_CONTROL_LOG"])
if not LOG_FILE.is_absolute():
LOG_FILE = CONFIG_FILE.parent / LOG_FILE
IP_ADDRESS = str(CONFIG.get("ip_address", "127.0.0.1"))
PORT = int(CONFIG.get("port", 5000))
URL_PREFIX = "/" + str(CONFIG.get("url_prefix", "")).strip("/")
if URL_PREFIX == "/":
URL_PREFIX = ""
app = Flask(__name__)
def _tail(path: Path, line_count: int = 50) -> str:
try:
with path.open("rb") as logfile:
logfile.seek(0, os.SEEK_END)
end = logfile.tell()
data = b""
while end > 0 and data.count(b"\n") <= line_count:
size = min(4096, end)
end -= size
logfile.seek(end)
data = logfile.read(size) + data
return b"\n".join(data.splitlines()[-line_count:]).decode("utf-8", "replace")
except FileNotFoundError:
return ""
@app.route(f"{URL_PREFIX}/event", methods=["GET", "POST"])
def add_event():
data = request.get_json(silent=True)
if data is None:
data = request.values.to_dict(flat=False)
if not data:
data = request.get_data(as_text=True)
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"remote_addr": request.remote_addr,
"event": data,
}
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with LOG_FILE.open("a", encoding="utf-8") as logfile:
logfile.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n")
return jsonify(ok=True), 201
@app.get(URL_PREFIX or "/", strict_slashes=False)
def show_log():
return Response(_tail(LOG_FILE), mimetype="text/plain")
if __name__ == "__main__":
app.run(host=IP_ADDRESS, port=PORT)
+157 -51
View File
@@ -3,12 +3,12 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from threading import Event, Thread
from typing import Callable
from lib._zyxel import ZyxelError, ZyxelRouter, normalize_mac from lib._zyxel import ZyxelError, ZyxelRouter, normalize_mac
from notify import send_notification
LOG = logging.getLogger("home_control.arrival_detection") LOG = logging.getLogger("home_control.arrival_detection")
@@ -25,7 +25,8 @@ class ArrivalDetector:
self.present_samples = 0 self.present_samples = 0
self.absent_samples = 0 self.absent_samples = 0
def sample(self, present: bool) -> bool: def transition(self, present: bool) -> str | None:
"""Return ``arrived`` or ``departed`` after a confirmed state change."""
self.present_samples = self.present_samples + 1 if present else 0 self.present_samples = self.present_samples + 1 if present else 0
self.absent_samples = self.absent_samples + 1 if not present else 0 self.absent_samples = self.absent_samples + 1 if not present else 0
if self.state is None: if self.state is None:
@@ -33,13 +34,18 @@ class ArrivalDetector:
self.state = True self.state = True
elif self.absent_samples >= self.absent_after: elif self.absent_samples >= self.absent_after:
self.state = False self.state = False
return False return None
if self.state and self.absent_samples >= self.absent_after: if self.state and self.absent_samples >= self.absent_after:
self.state = False self.state = False
return "departed"
elif not self.state and self.present_samples >= self.present_after: elif not self.state and self.present_samples >= self.present_after:
self.state = True self.state = True
return True return "arrived"
return False return None
def sample(self, present: bool) -> bool:
"""Backward-compatible arrival-only result."""
return self.transition(present) == "arrived"
def _build_router(config: dict) -> ZyxelRouter: def _build_router(config: dict) -> ZyxelRouter:
@@ -49,57 +55,157 @@ def _build_router(config: dict) -> ZyxelRouter:
timeout=float(router.get("timeout", 10)), timeout=float(router.get("timeout", 10)),
verify_tls=bool(router.get("verify_tls", True)), verify_tls=bool(router.get("verify_tls", True)),
hosts_oid=str(router.get("hosts_oid", "lanhosts")), hosts_oid=str(router.get("hosts_oid", "lanhosts")),
debug=bool(router.get("debug", False)),
) )
def _notify(config: dict, device: dict) -> None: def _record_event(config: dict, event: dict, mac: str = "") -> None:
LOG.info("ARRIVAL: %s", device["name"]) """Record a confirmed presence event before handing it to the controller."""
notification = config.get("notification", {}) LOG.info("%s: %s", event["event"].upper(), event["id"])
message = str(notification.get("message", "{name} arrived home")).format( event_log = config.get("event_log")
name=device["name"], mac=device["mac"]
)
event_log = notification.get("event_log")
if event_log: if event_log:
timestamp = datetime.now().astimezone().isoformat(timespec="seconds") timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
mac_field = f" mac={mac}" if mac else ""
with Path(event_log).open("a", encoding="utf-8") as stream: with Path(event_log).open("a", encoding="utf-8") as stream:
stream.write(f"{timestamp} {message}\n") stream.write(
if not notification.get("enabled", True): f"{timestamp} {event['event'].upper()} id={event['id']}{mac_field}\n"
return )
send_notification(
message,
topic_url=notification.get("topic_url") or None, def _record_arrival(config: dict, device: dict) -> None:
timeout=float(notification.get("timeout", 10)), """Compatibility helper for callers that record an arrival directly."""
_record_event(
config,
{"sender": "_arrival_detection", "event": "arrived", "id": device["id"], "text": ""},
device["mac"],
) )
def run(config: dict, stop_requested, *, once: bool = False) -> int: def _configured_devices(config: dict) -> list[dict]:
"""Run arrival detection until stop_requested returns true.""" devices = config.get("devices")
device = dict(config["device"]) if not isinstance(devices, list) or not devices:
device["mac"] = normalize_mac(device["mac"]) raise ValueError("arrival_detection.devices must contain at least one device")
polling = config.get("polling", {}) if len(devices) > 3:
detector = ArrivalDetector( raise ValueError("arrival_detection.devices supports at most 3 devices")
int(polling.get("absent_after", 3)), int(polling.get("present_after", 2)) normalized = []
) identifiers = set()
interval = float(polling.get("interval_seconds", 10)) for index, device in enumerate(devices, start=1):
retry = float(polling.get("error_retry_seconds", 30)) if not isinstance(device, dict) or not device.get("id") or not device.get("mac"):
router = _build_router(config) raise ValueError(f"arrival_detection.devices entry {index} requires id and mac")
try: item = dict(device)
while not stop_requested(): item["mac"] = normalize_mac(str(item["mac"]))
try: item["id"] = str(item["id"]).strip().lower()
present = router.is_connected(device["mac"]) if not item["id"]:
LOG.debug("%s is %s", device["name"], "connected" if present else "absent") raise ValueError(f"arrival_detection.devices entry {index} requires a non-empty id")
if detector.sample(present): if item["id"] in identifiers:
_notify(config, device) raise ValueError(f"duplicate arrival_detection device id: {item['id']}")
if once: identifiers.add(item["id"])
print("connected" if present else "absent") normalized.append(item)
return 0 return normalized
time.sleep(interval)
except (ZyxelError, OSError, RuntimeError) as error:
LOG.error("poll failed: %s", error) class ArrivalMonitor:
router.close() """Background Zyxel monitor that emits generic controller events."""
if once:
return 1 def __init__(self, config: dict, on_event: Callable[[dict], None]) -> None:
time.sleep(retry) self.config = config
finally: self.on_event = on_event
router.close() self.devices = _configured_devices(config)
return 0 polling = config.get("polling", {})
self.detectors = {
device["mac"]: ArrivalDetector(
int(polling.get("absent_after", 3)),
int(polling.get("present_after", 2)),
)
for device in self.devices
}
self.interval = float(polling.get("interval_seconds", 10))
self.retry = float(polling.get("error_retry_seconds", 30))
self.router = _build_router(config)
self._stop = Event()
self._thread: Thread | None = None
self._house_occupied: bool | None = None
def _connection_states(self) -> dict[str, bool]:
return self.router.connection_states([device["mac"] for device in self.devices])
def check_once(self) -> list[str]:
states = self._connection_states()
return [
f"{device['id']}={'connected' if states[device['mac']] else 'absent'}"
for device in self.devices
]
def _process_states(self, states: dict[str, bool]) -> None:
for device in self.devices:
present = states[device["mac"]]
LOG.debug(
"%s is %s",
device["id"],
"connected" if present else "absent",
)
transition = self.detectors[device["mac"]].transition(present)
if transition:
event = {
"sender": "_arrival_detection",
"event": transition,
"id": device["id"],
"text": "",
}
_record_event(self.config, event, device["mac"])
self.on_event(event)
known_states = [detector.state for detector in self.detectors.values()]
if all(state is not None for state in known_states):
occupied = any(known_states)
if self._house_occupied is True and not occupied:
event = {
"sender": "_arrival_detection",
"event": "empty",
"id": "house",
"text": "",
}
_record_event(self.config, event)
self.on_event(event)
self._house_occupied = occupied
def _run(self) -> None:
try:
while not self._stop.is_set():
try:
states = self._connection_states()
self._process_states(states)
self._stop.wait(self.interval)
except (ZyxelError, OSError, RuntimeError) as error:
LOG.error("poll failed: %s", error)
self.router.close()
self._stop.wait(self.retry)
finally:
self.router.close()
def start(self) -> None:
if self._thread and self._thread.is_alive():
raise RuntimeError("arrival monitor is already running")
self._thread = Thread(target=self._run, name="zyxel-arrival-monitor")
self._thread.start()
LOG.info("Zyxel arrival monitor started")
def stop(self) -> None:
self._stop.set()
def join(self, timeout: float | None = None) -> None:
if self._thread:
self._thread.join(timeout)
@property
def is_alive(self) -> bool:
return bool(self._thread and self._thread.is_alive())
def close(self) -> None:
"""Release resources after a one-shot check."""
self.router.close()
def create(config: dict, on_event: Callable[[dict], None]) -> ArrivalMonitor:
"""Generic controller plugin factory."""
return ArrivalMonitor(config, on_event)
+80
View File
@@ -0,0 +1,80 @@
"""Send controller events to the cloud event logger."""
import logging
from datetime import datetime, timezone
import requests
LOG = logging.getLogger("home_control.cloud_logger")
def _filter_values(config: dict, mode: str, field: str) -> set[str]:
value = config.get("filter", {}).get(mode, {}).get(field, [])
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
raise ValueError(
f"cloud_logger.filter.{mode}.{field} must be a list of strings"
)
return set(value)
def accepts_event(config: dict, message: dict) -> bool:
"""Return whether an event passes configured event and ID filters."""
accepted_events = _filter_values(config, "accept", "events")
accepted_ids = _filter_values(config, "accept", "ids")
ignored_events = _filter_values(config, "ignore", "events")
ignored_ids = _filter_values(config, "ignore", "ids")
if message["event"] in ignored_events or message["id"] in ignored_ids:
return False
if accepted_events and message["event"] not in accepted_events:
return False
if accepted_ids and message["id"] not in accepted_ids:
return False
return True
def send_event(message: dict, event_url: str, timeout: float = 10) -> None:
"""POST an event dictionary as JSON to the configured logger endpoint."""
if not isinstance(message, dict):
raise TypeError("message must be a dictionary")
if not event_url:
raise ValueError("event_url is required")
response = requests.post(event_url, json=message, timeout=timeout)
response.raise_for_status()
def on_event(config: dict, message: dict) -> None:
"""Controller action that forwards event data to the cloud logger."""
if not config.get("enabled", True):
return
try:
if not accepts_event(config, message):
LOG.debug(
"cloud event ignored by filter: event=%s id=%s",
message["event"],
message["id"],
)
return
timestamp = datetime.now(timezone.utc).isoformat()
values = {**message, "datetime": timestamp}
messages = config.get("messages", {})
template = messages.get(
message["event"],
config.get("message", "{datetime} {sender}: {event} ({id})"),
)
payload = {
**message,
"datetime": timestamp,
"text": message.get("text") or str(template).format(**values),
}
send_event(
payload,
event_url=str(config.get("event_url", "")),
timeout=float(config.get("timeout", 10)),
)
except (TypeError, ValueError, requests.RequestException) as error:
LOG.error("cloud event logging failed: %s", error)
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Send text notifications to a configured ntfy topic."""
import argparse
import logging
import os
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LOG = logging.getLogger("home_control.notify")
def send_notification(message, topic_url=None, timeout=10):
"""Send *message* to ntfy and return the server response body."""
if not isinstance(message, str):
raise TypeError("message must be a string")
if not message:
raise ValueError("message must not be empty")
url = topic_url or os.environ.get("NTFY_TOPIC_URL")
if not url:
raise ValueError("topic_url is required (or set NTFY_TOPIC_URL)")
request = Request(
url,
data=message.encode("utf-8"),
headers={"Content-Type": "text/plain; charset=utf-8"},
method="POST",
)
with urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8")
def _filter_values(config: dict, mode: str, field: str) -> set[str]:
value = config.get("filter", {}).get(mode, {}).get(field, [])
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
raise ValueError(f"notify.filter.{mode}.{field} must be a list of strings")
return set(value)
def accepts_event(config: dict, message: dict) -> bool:
"""Return whether an event passes configured event and ID filters."""
accepted_events = _filter_values(config, "accept", "events")
accepted_ids = _filter_values(config, "accept", "ids")
ignored_events = _filter_values(config, "ignore", "events")
ignored_ids = _filter_values(config, "ignore", "ids")
if message["event"] in ignored_events or message["id"] in ignored_ids:
return False
if accepted_events and message["event"] not in accepted_events:
return False
if accepted_ids and message["id"] not in accepted_ids:
return False
return True
def on_event(config: dict, message: dict) -> None:
"""Generic controller action for event dictionaries."""
if not config.get("enabled", True):
return
if not accepts_event(config, message):
LOG.debug(
"notification ignored by filter: event=%s id=%s",
message["event"],
message["id"],
)
return
messages = config.get("messages", {})
template = messages.get(message["event"], config.get("message", "{sender}: {event} ({id})"))
text = message["text"] or str(template).format(**message)
try:
send_notification(
text,
topic_url=config.get("topic_url") or None,
timeout=float(config.get("timeout", 10)),
)
except (OSError, RuntimeError, ValueError) as error:
LOG.error(
"notification failed for %s/%s/%s: %s",
message["sender"],
message["event"],
message["id"],
error,
)
def parse_arguments():
parser = argparse.ArgumentParser(description="Send a text message to an ntfy topic.")
parser.add_argument("message", nargs="+", help="notification text")
parser.add_argument("--topic-url", help="ntfy topic URL (or use NTFY_TOPIC_URL)")
parser.add_argument("--timeout", type=float, default=10, help="request timeout in seconds")
return parser.parse_args()
def main():
args = parse_arguments()
try:
print(
send_notification(
" ".join(args.message),
topic_url=args.topic_url,
timeout=args.timeout,
)
)
return 0
except HTTPError as error:
print(f"ntfy returned HTTP {error.code}: {error.reason}", file=sys.stderr)
except URLError as error:
print(f"Could not reach ntfy: {error.reason}", file=sys.stderr)
except (TypeError, ValueError) as error:
print(f"Invalid notification: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+37 -4
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import base64 import base64
import json import json
import logging
import os import os
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
@@ -19,6 +20,9 @@ from Crypto.PublicKey import RSA
from Crypto.Util.Padding import pad, unpad from Crypto.Util.Padding import pad, unpad
LOG = logging.getLogger("home_control.zyxel")
class ZyxelError(RuntimeError): class ZyxelError(RuntimeError):
"""The router rejected a request or returned an unexpected response.""" """The router rejected a request or returned an unexpected response."""
@@ -50,6 +54,7 @@ class ZyxelRouter:
timeout: float = 10, timeout: float = 10,
verify_tls: bool = True, verify_tls: bool = True,
hosts_oid: str = "lanhosts", hosts_oid: str = "lanhosts",
debug: bool = False,
) -> None: ) -> None:
if "://" not in host: if "://" not in host:
host = f"http://{host}" host = f"http://{host}"
@@ -62,9 +67,11 @@ class ZyxelRouter:
self.timeout = timeout self.timeout = timeout
self.verify_tls = verify_tls self.verify_tls = verify_tls
self.hosts_oid = hosts_oid self.hosts_oid = hosts_oid
self.debug = debug
self.session = requests.Session() self.session = requests.Session()
self._aes_key: bytes | None = None self._aes_key: bytes | None = None
self._session_key: str | None = None self._session_key: str | None = None
self._has_logged_connection = False
def __enter__(self) -> "ZyxelRouter": def __enter__(self) -> "ZyxelRouter":
self.login() self.login()
@@ -79,6 +86,8 @@ class ZyxelRouter:
try: try:
response = self.session.request(method, f"{self.url}{path}", **kwargs) response = self.session.request(method, f"{self.url}{path}", **kwargs)
response.raise_for_status() response.raise_for_status()
if self.debug:
LOG.info("Zyxel HTTP call: %s %s -> %s", method, path, response.status_code)
return response return response
except requests.RequestException as error: except requests.RequestException as error:
raise ZyxelError(f"Zyxel request failed: {error}") from error raise ZyxelError(f"Zyxel request failed: {error}") from error
@@ -120,6 +129,12 @@ class ZyxelRouter:
if result.get("result") != "ZCFG_SUCCESS" or not result.get("sessionkey"): if result.get("result") != "ZCFG_SUCCESS" or not result.get("sessionkey"):
raise ZyxelError(f"Zyxel login failed: {result.get('result', 'unknown error')}") raise ZyxelError(f"Zyxel login failed: {result.get('result', 'unknown error')}")
self._session_key = str(result["sessionkey"]) self._session_key = str(result["sessionkey"])
self._log_first_connection()
def _log_first_connection(self) -> None:
if not self._has_logged_connection:
LOG.info("Connected successfully to Zyxel router at %s", self.url)
self._has_logged_connection = True
def _decrypt(self, envelope: dict[str, Any]) -> dict[str, Any]: def _decrypt(self, envelope: dict[str, Any]) -> dict[str, Any]:
if "content" not in envelope or "iv" not in envelope: if "content" not in envelope or "iv" not in envelope:
@@ -143,7 +158,10 @@ class ZyxelRouter:
response = self._request( response = self._request(
"GET", "/cgi-bin/DAL", params={"oid": oid, "sessionkey": self._session_key} "GET", "/cgi-bin/DAL", params={"oid": oid, "sessionkey": self._session_key}
) )
return self._decrypt(self._json(response)) result = self._decrypt(self._json(response))
if self.debug:
LOG.info("Zyxel DAL call: oid=%s -> %s", oid, result.get("result", "no result"))
return result
def get_lan_hosts(self) -> list[LanHost]: def get_lan_hosts(self) -> list[LanHost]:
result = self.dal_get(self.hosts_oid) result = self.dal_get(self.hosts_oid)
@@ -184,9 +202,23 @@ class ZyxelRouter:
)) ))
return hosts return hosts
def connection_states(self, macs: list[str]) -> dict[str, bool]:
"""Check several MAC addresses using one router query."""
wanted = [normalize_mac(mac) for mac in macs]
active_macs = {host.mac for host in self.get_lan_hosts() if host.active}
states = {mac: mac in active_macs for mac in wanted}
if self.debug:
for mac, connected in states.items():
LOG.info(
"Zyxel presence result: mac=%s -> %s",
mac,
"connected" if connected else "absent",
)
return states
def is_connected(self, mac: str) -> bool: def is_connected(self, mac: str) -> bool:
wanted = normalize_mac(mac) wanted = normalize_mac(mac)
return any(host.mac == wanted and host.active for host in self.get_lan_hosts()) return self.connection_states([wanted])[wanted]
def close(self) -> None: def close(self) -> None:
if self._session_key: if self._session_key:
@@ -204,7 +236,7 @@ def _load_cli_config(path: Path) -> tuple[dict[str, Any], str]:
with path.open(encoding="utf-8") as stream: with path.open(encoding="utf-8") as stream:
root = yaml.safe_load(stream) root = yaml.safe_load(stream)
arrival = root["arrival_detection"] arrival = root["arrival_detection"]
return arrival["zyxel"], str(arrival["device"]["mac"]) return arrival["zyxel"], str(arrival["devices"][0]["mac"])
except (OSError, TypeError, KeyError, yaml.YAMLError) as error: except (OSError, TypeError, KeyError, yaml.YAMLError) as error:
raise ZyxelError(f"could not load configuration from {path}: {error}") from error raise ZyxelError(f"could not load configuration from {path}: {error}") from error
@@ -221,7 +253,7 @@ def main() -> int:
) )
parser.add_argument( parser.add_argument(
"--mac", "--mac",
help="MAC address to check (default: arrival_detection.device.mac)", help="MAC address to check (default: first arrival_detection.devices entry)",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -235,6 +267,7 @@ def main() -> int:
timeout=float(config.get("timeout", 10)), timeout=float(config.get("timeout", 10)),
verify_tls=bool(config.get("verify_tls", True)), verify_tls=bool(config.get("verify_tls", True)),
hosts_oid=str(config.get("hosts_oid", "lanhosts")), hosts_oid=str(config.get("hosts_oid", "lanhosts")),
debug=bool(config.get("debug", False)),
) )
try: try:
connected = router.is_connected(mac) connected = router.is_connected(mac)
+3
View File
@@ -0,0 +1,3 @@
2026-08-22T22:27:23+02:00 ARRIVAL name=Ignace mac=ce:68:53:a8:fc:07
2026-08-23T08:09:35+02:00 DEPARTED id=ignace mac=ce:68:53:a8:fc:07
2026-08-23T08:10:47+02:00 ARRIVED id=ignace mac=ce:68:53:a8:fc:07
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""Send a text notification to the Home Alert ntfy topic."""
import argparse
import os
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_TOPIC_URL = (
"https://ntfy.sh/VK20_Home_Alert_______4671938674123049873459"
)
def send_notification(
message,
topic_url=None,
timeout=10,
):
"""Send *message* to ntfy and return the server response body."""
if not isinstance(message, str):
raise TypeError("message must be a string")
if not message:
raise ValueError("message must not be empty")
url = topic_url or os.environ.get(
"NTFY_TOPIC_URL",
DEFAULT_TOPIC_URL,
)
request = Request(
url,
data=message.encode("utf-8"),
headers={"Content-Type": "text/plain; charset=utf-8"},
method="POST",
)
with urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8")
def parse_arguments():
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Send a text message to the Home Alert ntfy topic.",
)
parser.add_argument(
"message",
nargs="+",
help="notification text",
)
parser.add_argument(
"--topic-url",
help="override the ntfy topic URL",
)
parser.add_argument(
"--timeout",
type=float,
default=10,
help="request timeout in seconds (default: 10)",
)
return parser.parse_args()
def main():
args = parse_arguments()
try:
response = send_notification(
" ".join(args.message),
topic_url=args.topic_url,
timeout=args.timeout,
)
print(response)
return 0
except HTTPError as exc:
print(
f"ntfy returned HTTP {exc.code}: {exc.reason}",
file=sys.stderr,
)
except URLError as exc:
print(f"Could not reach ntfy: {exc.reason}", file=sys.stderr)
except (TypeError, ValueError) as exc:
print(f"Invalid notification: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+141 -18
View File
@@ -1,16 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Entry point and orchestrator for enabled home-control functions.""" """Configuration-driven event controller for home-control plugins."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import importlib
import logging import logging
import signal import signal
from pathlib import Path from pathlib import Path
from threading import Event
from typing import Any, Callable
import yaml import yaml
from lib import _arrival_detection
LOG = logging.getLogger("home_control")
EVENT_FIELDS = ("sender", "event", "id", "text")
def load_config(path: Path) -> dict: def load_config(path: Path) -> dict:
@@ -21,33 +26,151 @@ def load_config(path: Path) -> dict:
return config return config
def run(config: dict, once: bool = False) -> int: def _configured_callable(spec: dict, key: str, default: str) -> Callable:
stopped = False module_name = spec.get("module")
if not isinstance(module_name, str) or not module_name.startswith("lib."):
raise ValueError("component module must be inside the lib package")
attribute = spec.get(key, default)
if not isinstance(attribute, str):
raise ValueError(f"component {key} must be a string")
try:
function = getattr(importlib.import_module(module_name), attribute)
except (ImportError, AttributeError) as error:
raise ValueError(f"could not load {module_name}.{attribute}: {error}") from error
if not callable(function):
raise ValueError(f"configured component {module_name}.{attribute} is not callable")
return function
def stop(*_: object) -> None:
nonlocal stopped
stopped = True
signal.signal(signal.SIGTERM, stop) class Controller:
signal.signal(signal.SIGINT, stop) """Load, supervise, and route plugins without plugin-specific code."""
arrival_config = config.get("arrival_detection") def __init__(self, config: dict) -> None:
if not isinstance(arrival_config, dict): self.config = config
raise ValueError("arrival_detection configuration is required") controller = config.get("controller", {})
if not arrival_config.get("enabled", True): self.plugin_specs = controller.get("plugins", {})
logging.getLogger("home_control").info("arrival detection is disabled") self.action_specs = controller.get("actions", {})
if not isinstance(self.plugin_specs, dict):
raise ValueError("controller.plugins must be a mapping")
if not isinstance(self.action_specs, dict):
raise ValueError("controller.actions must be a mapping")
self.stopped = Event()
self.plugins: list[Any] = []
@staticmethod
def _validate_event(message: dict) -> None:
if not isinstance(message, dict):
raise ValueError("plugin event must be a dictionary")
missing = [field for field in EVENT_FIELDS if field not in message]
if missing:
raise ValueError(f"plugin event is missing fields: {', '.join(missing)}")
if any(not isinstance(message[field], str) for field in EVENT_FIELDS):
raise ValueError("plugin event fields must all be strings")
def _component_config(self, spec: dict) -> dict:
section = spec.get("config")
if not isinstance(section, str):
raise ValueError("component config must name a configuration section")
component_config = self.config.get(section)
if not isinstance(component_config, dict):
raise ValueError(f"configuration section {section!r} is required")
return component_config
def handle_event(self, message: dict) -> None:
"""Route a validated plugin event to its configured action."""
self._validate_event(message)
plugin_spec = self.plugin_specs.get(message["sender"])
if not isinstance(plugin_spec, dict):
LOG.warning("no controller route for plugin %s", message["sender"])
return
configured_actions = plugin_spec.get("on_event")
action_names = (
[configured_actions]
if isinstance(configured_actions, str)
else configured_actions
)
if not isinstance(action_names, list) or any(
not isinstance(name, str) for name in action_names
):
raise ValueError(
f"on_event for plugin {message['sender']!r} must be a string or list of strings"
)
for action_name in action_names:
action_spec = self.action_specs.get(action_name)
if not isinstance(action_spec, dict):
raise ValueError(
f"unknown on_event action {action_name!r} "
f"for plugin {message['sender']!r}"
)
handler = _configured_callable(action_spec, "handler", "on_event")
handler(self._component_config(action_spec), message)
def _load_plugins(self) -> None:
for name, spec in self.plugin_specs.items():
if not isinstance(spec, dict):
raise ValueError(f"controller plugin {name!r} must be a mapping")
plugin_config = self._component_config(spec)
if not plugin_config.get("enabled", True):
LOG.info("plugin %s is disabled", name)
continue
factory = _configured_callable(spec, "factory", "create")
plugin = factory(plugin_config, self.handle_event)
self.plugins.append(plugin)
def run(self) -> int:
self._load_plugins()
for plugin in self.plugins:
plugin.start()
try:
while not self.stopped.wait(1):
if any(not plugin.is_alive for plugin in self.plugins):
LOG.error("a controller plugin stopped unexpectedly")
return 1
finally:
for plugin in self.plugins:
plugin.stop()
for plugin in self.plugins:
plugin.join(timeout=20)
return 0 return 0
return _arrival_detection.run(arrival_config, lambda: stopped, once=once)
def stop(self, *_: object) -> None:
self.stopped.set()
def check_once(self) -> int:
self._load_plugins()
result = 0
for plugin in self.plugins:
try:
check_once = getattr(plugin, "check_once", None)
if callable(check_once):
output = check_once()
if output:
print("\n".join(output) if isinstance(output, list) else output)
except (OSError, RuntimeError) as error:
LOG.error("plugin check failed: %s", error)
result = 1
finally:
close = getattr(plugin, "close", None)
if callable(close):
close()
return result
def run(config: dict, once: bool = False) -> int:
controller = Controller(config)
signal.signal(signal.SIGTERM, controller.stop)
signal.signal(signal.SIGINT, controller.stop)
return controller.check_once() if once else controller.run()
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, default=Path(__file__).with_name("service.yaml")) parser.add_argument("--config", type=Path, default=Path(__file__).with_name("service.yaml"))
parser.add_argument("--once", action="store_true", help="check once and print connected/absent") parser.add_argument("--once", action="store_true", help="check plugins once")
args = parser.parse_args() args = parser.parse_args()
config = load_config(args.config) config = load_config(args.config)
arrival = config.get("arrival_detection", {}) logging_config = config.get("controller", {}).get("logging", {})
logging_config = arrival.get("logging", {}) if isinstance(arrival, dict) else {}
logging.basicConfig( logging.basicConfig(
level=getattr(logging, str(logging_config.get("level", "INFO")).upper()), level=getattr(logging, str(logging_config.get("level", "INFO")).upper()),
format="%(asctime)s %(levelname)s %(message)s", format="%(asctime)s %(levelname)s %(message)s",
+64 -15
View File
@@ -1,3 +1,23 @@
controller:
plugins:
_arrival_detection:
module: lib._arrival_detection
factory: create
config: arrival_detection
on_event: [_notify, _cloud_logger]
actions:
_notify:
module: lib._notify
handler: on_event
config: notify
_cloud_logger:
module: lib._cloud_logger
handler: on_event
config: cloud_logger
logging:
level: INFO
arrival_detection: arrival_detection:
enabled: true enabled: true
@@ -8,12 +28,16 @@ arrival_detection:
timeout: 10 timeout: 10
verify_tls: true verify_tls: true
hosts_oid: lanhosts hosts_oid: lanhosts
# Log every router/API call and its result without logging credentials.
debug: true
device: devices:
name: Ignace - id: ignace
# Use the iPhone's Wi-Fi Address shown for your home SSID. If Private Wi-Fi mac: "ce:68:53:a8:fc:07"
# Address is enabled, this is the per-network private address, not hardware MAC. - id: janine
mac: "ce:68:53:a8:fc:07" mac: "dc:10:57:9b:85:0e"
- id: Fatima
mac: "02:f8:26:33:2f:d9"
polling: polling:
interval_seconds: 5 interval_seconds: 5
@@ -21,14 +45,39 @@ arrival_detection:
absent_after: 4 absent_after: 4
error_retry_seconds: 30 error_retry_seconds: 30
notification: event_log: log/detect-arrivals.txt
enabled: true
# Available placeholders: {name}, {mac}.
message: "{name} arrived home"
event_log: /log/detect-arrivals.txt
# Leave blank to use the default ntfy topic configured in notify.py.
topic_url: ""
timeout: 10
logging: notify:
level: INFO enabled: true
filter:
# Empty accept lists allow all values. Ignore lists always take precedence.
accept:
events: [empty]
ids: []
ignore:
events: [arrived, departed]
ids: []
# Available placeholders: {sender}, {event}, {id}, {text}.
message: "{sender}: {event} ({id})"
messages:
arrived: "{id} arrived home"
departed: "{id} left home"
empty: "The house is empty"
# May also be supplied through the NTFY_TOPIC_URL environment variable.
topic_url: "https://ntfy.sh/VK20_Home_Alert_______4671938674123049873459"
timeout: 10
cloud_logger:
enabled: true
event_url: http://127.0.0.1:5000/home_logger/event
timeout: 10
filter:
# Empty accept lists allow all values. Ignore lists always take precedence.
accept:
events: [empty]
ids: []
ignore:
events: [arrived, departed]
ids: []
# Available placeholders: {sender}, {event}, {id}, {text}.
message: "{datetime} {sender}: {event} ({id})"
+248 -17
View File
@@ -4,7 +4,14 @@ from tempfile import TemporaryDirectory
from unittest.mock import patch from unittest.mock import patch
from lib._zyxel import ZyxelRouter, normalize_mac from lib._zyxel import ZyxelRouter, normalize_mac
from lib._arrival_detection import ArrivalDetector, _notify from lib._arrival_detection import (
ArrivalDetector,
ArrivalMonitor,
_configured_devices,
_record_arrival,
)
from lib._notify import accepts_event
from service import Controller
class ArrivalDetectorTests(unittest.TestCase): class ArrivalDetectorTests(unittest.TestCase):
@@ -26,6 +33,93 @@ class ArrivalDetectorTests(unittest.TestCase):
self.assertFalse(detector.sample(False)) self.assertFalse(detector.sample(False))
self.assertFalse(detector.sample(True)) self.assertFalse(detector.sample(True))
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": "",
}
],
)
@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, [])
class MacTests(unittest.TestCase): class MacTests(unittest.TestCase):
def test_normalizes_common_formats(self): def test_normalizes_common_formats(self):
@@ -57,38 +151,175 @@ class MacTests(unittest.TestCase):
self.assertEqual(hosts[0].mac, "2c:db:07:50:2b:5c") self.assertEqual(hosts[0].mac, "2c:db:07:50:2b:5c")
self.assertTrue(router.is_connected("2c-db-07-50-2b-5c")) self.assertTrue(router.is_connected("2c-db-07-50-2b-5c"))
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])
class NotificationTests(unittest.TestCase): class NotificationTests(unittest.TestCase):
@patch("lib._arrival_detection.send_notification") @patch("lib._cloud_logger.send_event")
def test_uses_notify_module(self, send_notification): @patch("lib._notify.send_notification")
def test_controller_routes_event_to_multiple_actions(
self, send_notification, send_event
):
config = { config = {
"notification": { "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):
config = {
"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": {
"enabled": True, "enabled": True,
"message": "Welcome home, {name} ({mac})", "message": "Welcome home, {id}",
"topic_url": "https://ntfy.example/home", "topic_url": "https://ntfy.example/home",
"timeout": 4, "timeout": 4,
} },
} }
_notify(config, {"name": "Ignace", "mac": "aa:bb:cc:dd:ee:ff"}) Controller(config).handle_event(
{
"sender": "_arrival_detection",
"event": "arrived",
"id": "ignace",
"text": "",
}
)
send_notification.assert_called_once_with( send_notification.assert_called_once_with(
"Welcome home, Ignace (aa:bb:cc:dd:ee:ff)", "Welcome home, ignace",
topic_url="https://ntfy.example/home", topic_url="https://ntfy.example/home",
timeout=4.0, timeout=4.0,
) )
@patch("lib._arrival_detection.send_notification") def test_controller_has_no_plugin_specific_setup(self):
def test_appends_notification_to_event_log(self, send_notification): 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):
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
event_log = Path(directory) / "arrivals.txt" event_log = Path(directory) / "arrivals.txt"
config = { config = {
"notification": { "event_log": str(event_log),
"enabled": True,
"message": "{name} arrived home",
"event_log": str(event_log),
}
} }
_notify(config, {"name": "Ignace", "mac": "aa:bb:cc:dd:ee:ff"}) _record_arrival(
self.assertTrue(event_log.read_text().endswith(" Ignace arrived home\n")) 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"
)
)
if __name__ == "__main__": if __name__ == "__main__":