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
+157 -51
View File
@@ -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)