included cloud logging with filters
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
HOME_CONTROL_LOG: flask_home_control_log.log
|
||||||
|
ip_address: 127.0.0.1
|
||||||
|
port: 5000
|
||||||
|
url_prefix: /home_logger
|
||||||
@@ -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":""}}
|
||||||
@@ -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)
|
||||||
+150
-44
@@ -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,
|
|
||||||
timeout=float(notification.get("timeout", 10)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run(config: dict, stop_requested, *, once: bool = False) -> int:
|
def _record_arrival(config: dict, device: dict) -> None:
|
||||||
"""Run arrival detection until stop_requested returns true."""
|
"""Compatibility helper for callers that record an arrival directly."""
|
||||||
device = dict(config["device"])
|
_record_event(
|
||||||
device["mac"] = normalize_mac(device["mac"])
|
config,
|
||||||
|
{"sender": "_arrival_detection", "event": "arrived", "id": device["id"], "text": ""},
|
||||||
|
device["mac"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_devices(config: dict) -> list[dict]:
|
||||||
|
devices = config.get("devices")
|
||||||
|
if not isinstance(devices, list) or not devices:
|
||||||
|
raise ValueError("arrival_detection.devices must contain at least one device")
|
||||||
|
if len(devices) > 3:
|
||||||
|
raise ValueError("arrival_detection.devices supports at most 3 devices")
|
||||||
|
normalized = []
|
||||||
|
identifiers = set()
|
||||||
|
for index, device in enumerate(devices, start=1):
|
||||||
|
if not isinstance(device, dict) or not device.get("id") or not device.get("mac"):
|
||||||
|
raise ValueError(f"arrival_detection.devices entry {index} requires id and mac")
|
||||||
|
item = dict(device)
|
||||||
|
item["mac"] = normalize_mac(str(item["mac"]))
|
||||||
|
item["id"] = str(item["id"]).strip().lower()
|
||||||
|
if not item["id"]:
|
||||||
|
raise ValueError(f"arrival_detection.devices entry {index} requires a non-empty id")
|
||||||
|
if item["id"] in identifiers:
|
||||||
|
raise ValueError(f"duplicate arrival_detection device id: {item['id']}")
|
||||||
|
identifiers.add(item["id"])
|
||||||
|
normalized.append(item)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ArrivalMonitor:
|
||||||
|
"""Background Zyxel monitor that emits generic controller events."""
|
||||||
|
|
||||||
|
def __init__(self, config: dict, on_event: Callable[[dict], None]) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.on_event = on_event
|
||||||
|
self.devices = _configured_devices(config)
|
||||||
polling = config.get("polling", {})
|
polling = config.get("polling", {})
|
||||||
detector = ArrivalDetector(
|
self.detectors = {
|
||||||
int(polling.get("absent_after", 3)), int(polling.get("present_after", 2))
|
device["mac"]: ArrivalDetector(
|
||||||
|
int(polling.get("absent_after", 3)),
|
||||||
|
int(polling.get("present_after", 2)),
|
||||||
)
|
)
|
||||||
interval = float(polling.get("interval_seconds", 10))
|
for device in self.devices
|
||||||
retry = float(polling.get("error_retry_seconds", 30))
|
}
|
||||||
router = _build_router(config)
|
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:
|
try:
|
||||||
while not stop_requested():
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
present = router.is_connected(device["mac"])
|
states = self._connection_states()
|
||||||
LOG.debug("%s is %s", device["name"], "connected" if present else "absent")
|
self._process_states(states)
|
||||||
if detector.sample(present):
|
self._stop.wait(self.interval)
|
||||||
_notify(config, device)
|
|
||||||
if once:
|
|
||||||
print("connected" if present else "absent")
|
|
||||||
return 0
|
|
||||||
time.sleep(interval)
|
|
||||||
except (ZyxelError, OSError, RuntimeError) as error:
|
except (ZyxelError, OSError, RuntimeError) as error:
|
||||||
LOG.error("poll failed: %s", error)
|
LOG.error("poll failed: %s", error)
|
||||||
router.close()
|
self.router.close()
|
||||||
if once:
|
self._stop.wait(self.retry)
|
||||||
return 1
|
|
||||||
time.sleep(retry)
|
|
||||||
finally:
|
finally:
|
||||||
router.close()
|
self.router.close()
|
||||||
return 0
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
+61
-12
@@ -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
|
|
||||||
# Address is enabled, this is the per-network private address, not hardware MAC.
|
|
||||||
mac: "ce:68:53:a8:fc:07"
|
mac: "ce:68:53:a8:fc:07"
|
||||||
|
- id: janine
|
||||||
|
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
|
||||||
|
|
||||||
|
notify:
|
||||||
enabled: true
|
enabled: true
|
||||||
# Available placeholders: {name}, {mac}.
|
filter:
|
||||||
message: "{name} arrived home"
|
# Empty accept lists allow all values. Ignore lists always take precedence.
|
||||||
event_log: /log/detect-arrivals.txt
|
accept:
|
||||||
# Leave blank to use the default ntfy topic configured in notify.py.
|
events: [empty]
|
||||||
topic_url: ""
|
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
|
timeout: 10
|
||||||
|
|
||||||
logging:
|
cloud_logger:
|
||||||
level: INFO
|
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})"
|
||||||
+246
-15
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
Controller(config).handle_event(
|
||||||
|
{
|
||||||
|
"sender": "_arrival_detection",
|
||||||
|
"event": "arrived",
|
||||||
|
"id": "ignace",
|
||||||
|
"text": "",
|
||||||
}
|
}
|
||||||
_notify(config, {"name": "Ignace", "mac": "aa:bb:cc:dd:ee:ff"})
|
)
|
||||||
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": {
|
|
||||||
"enabled": True,
|
|
||||||
"message": "{name} arrived home",
|
|
||||||
"event_log": str(event_log),
|
"event_log": str(event_log),
|
||||||
}
|
}
|
||||||
}
|
_record_arrival(
|
||||||
_notify(config, {"name": "Ignace", "mac": "aa:bb:cc:dd:ee:ff"})
|
config, {"id": "ignace", "mac": "aa:bb:cc:dd:ee:ff"}
|
||||||
self.assertTrue(event_log.read_text().endswith(" Ignace arrived home\n"))
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
event_log.read_text().endswith(
|
||||||
|
" ARRIVED id=ignace mac=aa:bb:cc:dd:ee:ff\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user