included cloud logging with filters
This commit is contained in:
+157
-51
@@ -3,12 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
from typing import Callable
|
||||
|
||||
from lib._zyxel import ZyxelError, ZyxelRouter, normalize_mac
|
||||
from notify import send_notification
|
||||
|
||||
LOG = logging.getLogger("home_control.arrival_detection")
|
||||
|
||||
@@ -25,7 +25,8 @@ class ArrivalDetector:
|
||||
self.present_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.absent_samples = self.absent_samples + 1 if not present else 0
|
||||
if self.state is None:
|
||||
@@ -33,13 +34,18 @@ class ArrivalDetector:
|
||||
self.state = True
|
||||
elif self.absent_samples >= self.absent_after:
|
||||
self.state = False
|
||||
return False
|
||||
return None
|
||||
if self.state and self.absent_samples >= self.absent_after:
|
||||
self.state = False
|
||||
return "departed"
|
||||
elif not self.state and self.present_samples >= self.present_after:
|
||||
self.state = True
|
||||
return True
|
||||
return False
|
||||
return "arrived"
|
||||
return None
|
||||
|
||||
def sample(self, present: bool) -> bool:
|
||||
"""Backward-compatible arrival-only result."""
|
||||
return self.transition(present) == "arrived"
|
||||
|
||||
|
||||
def _build_router(config: dict) -> ZyxelRouter:
|
||||
@@ -49,57 +55,157 @@ def _build_router(config: dict) -> ZyxelRouter:
|
||||
timeout=float(router.get("timeout", 10)),
|
||||
verify_tls=bool(router.get("verify_tls", True)),
|
||||
hosts_oid=str(router.get("hosts_oid", "lanhosts")),
|
||||
debug=bool(router.get("debug", False)),
|
||||
)
|
||||
|
||||
|
||||
def _notify(config: dict, device: dict) -> None:
|
||||
LOG.info("ARRIVAL: %s", device["name"])
|
||||
notification = config.get("notification", {})
|
||||
message = str(notification.get("message", "{name} arrived home")).format(
|
||||
name=device["name"], mac=device["mac"]
|
||||
)
|
||||
event_log = notification.get("event_log")
|
||||
def _record_event(config: dict, event: dict, mac: str = "") -> None:
|
||||
"""Record a confirmed presence event before handing it to the controller."""
|
||||
LOG.info("%s: %s", event["event"].upper(), event["id"])
|
||||
event_log = config.get("event_log")
|
||||
if event_log:
|
||||
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:
|
||||
stream.write(f"{timestamp} {message}\n")
|
||||
if not notification.get("enabled", True):
|
||||
return
|
||||
send_notification(
|
||||
message,
|
||||
topic_url=notification.get("topic_url") or None,
|
||||
timeout=float(notification.get("timeout", 10)),
|
||||
stream.write(
|
||||
f"{timestamp} {event['event'].upper()} id={event['id']}{mac_field}\n"
|
||||
)
|
||||
|
||||
|
||||
def _record_arrival(config: dict, device: dict) -> None:
|
||||
"""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:
|
||||
"""Run arrival detection until stop_requested returns true."""
|
||||
device = dict(config["device"])
|
||||
device["mac"] = normalize_mac(device["mac"])
|
||||
polling = config.get("polling", {})
|
||||
detector = ArrivalDetector(
|
||||
int(polling.get("absent_after", 3)), int(polling.get("present_after", 2))
|
||||
)
|
||||
interval = float(polling.get("interval_seconds", 10))
|
||||
retry = float(polling.get("error_retry_seconds", 30))
|
||||
router = _build_router(config)
|
||||
try:
|
||||
while not stop_requested():
|
||||
try:
|
||||
present = router.is_connected(device["mac"])
|
||||
LOG.debug("%s is %s", device["name"], "connected" if present else "absent")
|
||||
if detector.sample(present):
|
||||
_notify(config, device)
|
||||
if once:
|
||||
print("connected" if present else "absent")
|
||||
return 0
|
||||
time.sleep(interval)
|
||||
except (ZyxelError, OSError, RuntimeError) as error:
|
||||
LOG.error("poll failed: %s", error)
|
||||
router.close()
|
||||
if once:
|
||||
return 1
|
||||
time.sleep(retry)
|
||||
finally:
|
||||
router.close()
|
||||
return 0
|
||||
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", {})
|
||||
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)
|
||||
|
||||
@@ -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 base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -19,6 +20,9 @@ from Crypto.PublicKey import RSA
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
|
||||
LOG = logging.getLogger("home_control.zyxel")
|
||||
|
||||
|
||||
class ZyxelError(RuntimeError):
|
||||
"""The router rejected a request or returned an unexpected response."""
|
||||
|
||||
@@ -50,6 +54,7 @@ class ZyxelRouter:
|
||||
timeout: float = 10,
|
||||
verify_tls: bool = True,
|
||||
hosts_oid: str = "lanhosts",
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
if "://" not in host:
|
||||
host = f"http://{host}"
|
||||
@@ -62,9 +67,11 @@ class ZyxelRouter:
|
||||
self.timeout = timeout
|
||||
self.verify_tls = verify_tls
|
||||
self.hosts_oid = hosts_oid
|
||||
self.debug = debug
|
||||
self.session = requests.Session()
|
||||
self._aes_key: bytes | None = None
|
||||
self._session_key: str | None = None
|
||||
self._has_logged_connection = False
|
||||
|
||||
def __enter__(self) -> "ZyxelRouter":
|
||||
self.login()
|
||||
@@ -79,6 +86,8 @@ class ZyxelRouter:
|
||||
try:
|
||||
response = self.session.request(method, f"{self.url}{path}", **kwargs)
|
||||
response.raise_for_status()
|
||||
if self.debug:
|
||||
LOG.info("Zyxel HTTP call: %s %s -> %s", method, path, response.status_code)
|
||||
return response
|
||||
except requests.RequestException as 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"):
|
||||
raise ZyxelError(f"Zyxel login failed: {result.get('result', 'unknown error')}")
|
||||
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]:
|
||||
if "content" not in envelope or "iv" not in envelope:
|
||||
@@ -143,7 +158,10 @@ class ZyxelRouter:
|
||||
response = self._request(
|
||||
"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]:
|
||||
result = self.dal_get(self.hosts_oid)
|
||||
@@ -184,9 +202,23 @@ class ZyxelRouter:
|
||||
))
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
root = yaml.safe_load(stream)
|
||||
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:
|
||||
raise ZyxelError(f"could not load configuration from {path}: {error}") from error
|
||||
|
||||
@@ -221,7 +253,7 @@ def main() -> int:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--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()
|
||||
|
||||
@@ -235,6 +267,7 @@ def main() -> int:
|
||||
timeout=float(config.get("timeout", 10)),
|
||||
verify_tls=bool(config.get("verify_tls", True)),
|
||||
hosts_oid=str(config.get("hosts_oid", "lanhosts")),
|
||||
debug=bool(config.get("debug", False)),
|
||||
)
|
||||
try:
|
||||
connected = router.is_connected(mac)
|
||||
|
||||
Reference in New Issue
Block a user