Files
vigilar/vigilar/sensors/bridge.py
Aaron D. Lee 10b0cf4d0e Add events/rules engine, sensor bridge, and UPS monitor (Phases 6-8)
Phase 6 — Events + Rule Engine:
- EventProcessor subprocess: subscribes to all MQTT events, logs to DB,
  evaluates rules, fires alert actions
- ArmStateFSM: DISARMED/ARMED_HOME/ARMED_AWAY with PIN verification
  (HMAC-safe), DB persistence, MQTT state publishing
- RuleEngine: AND/OR logic, 4 condition types (arm_state, sensor_event,
  camera_motion, time_window), per-rule cooldown tracking
- SSE event stream with subscriber queue pattern and keepalive
- Event acknowledge endpoint

Phase 7 — Sensor Bridge:
- SensorBridge subprocess: subscribes to Zigbee2MQTT, normalizes payloads
  (contact, occupancy, temperature, humidity, battery, linkquality)
- GPIOHandler: conditional gpiozero import, callbacks for reed switches
  and PIR sensors
- SensorRegistry: maps Zigbee addresses and names to config sensor IDs
- SensorEvent/SensorState dataclasses
- Web UI now shows real sensor states from DB

Phase 8 — UPS Monitor:
- UPSMonitor subprocess: polls NUT via pynut2 with reconnect backoff
- State transition detection: OL→OB (power_loss), charge/runtime
  thresholds (low_battery, critical), OB→OL (restored)
- ShutdownSequence: ordered shutdown with configurable delay and command
- All conditionally imported (pynut2, gpiozero) for non-target platforms

Fixed test_db fixture to use isolated engines (no global singleton leak).
96 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:17:53 -04:00

202 lines
6.3 KiB
Python

"""Sensor Bridge — subprocess that bridges Zigbee2MQTT to the Vigilar internal bus."""
from __future__ import annotations
import logging
import signal
import time
from typing import Any
from vigilar.bus import MessageBus
from vigilar.config import MQTTConfig, VigilarConfig
from vigilar.constants import EventType, SensorProtocol, Topics
from vigilar.sensors.models import SensorEvent
from vigilar.sensors.registry import SensorRegistry
from vigilar.storage.db import get_db_path, get_engine
from vigilar.storage.queries import upsert_sensor_state
log = logging.getLogger(__name__)
def normalize_zigbee_payload(
raw: dict[str, Any],
) -> list[dict[str, Any]]:
"""Normalize a Zigbee2MQTT payload into a list of internal event dicts.
Each dict has keys: event_type, and one of (state, value, pct, unit).
"""
results: list[dict[str, Any]] = []
ts = int(time.time() * 1000)
# Contact sensor
if "contact" in raw:
contact = raw["contact"]
state = "CLOSED" if contact else "OPEN"
event_type = EventType.CONTACT_CLOSED if contact else EventType.CONTACT_OPEN
results.append({
"event_type": event_type,
"state": state,
"ts": ts,
"source": "zigbee",
})
# Motion / occupancy sensor
if "occupancy" in raw:
occupancy = raw["occupancy"]
state = "ACTIVE" if occupancy else "IDLE"
event_type = EventType.MOTION_START if occupancy else EventType.MOTION_END
results.append({
"event_type": event_type,
"state": state,
"ts": ts,
"source": "zigbee",
})
# Temperature
if "temperature" in raw:
results.append({
"event_type": "temperature",
"value": float(raw["temperature"]),
"unit": "C",
"ts": ts,
"source": "zigbee",
})
# Humidity
if "humidity" in raw:
results.append({
"event_type": "humidity",
"value": float(raw["humidity"]),
"ts": ts,
"source": "zigbee",
})
# Battery
if "battery" in raw:
results.append({
"event_type": "battery",
"pct": int(raw["battery"]),
"ts": ts,
"source": "zigbee",
})
# Link quality / signal strength
if "linkquality" in raw:
results.append({
"event_type": "linkquality",
"value": int(raw["linkquality"]),
"ts": ts,
"source": "zigbee",
})
return results
class SensorBridge:
"""Bridges Zigbee2MQTT messages to the internal Vigilar MQTT bus."""
def __init__(self, config: VigilarConfig) -> None:
self._config = config
self._registry = SensorRegistry(config)
self._z2m_prefix = config.zigbee2mqtt.mqtt_topic_prefix
self._bus = MessageBus(config.mqtt, client_id="vigilar-sensor-bridge")
self._engine = get_engine(get_db_path(config.system.data_dir))
self._running = False
def start(self) -> None:
"""Connect to MQTT and start processing sensor messages."""
self._bus.connect()
self._running = True
# Subscribe to zigbee2mqtt topics
z2m_wildcard = f"{self._z2m_prefix}/#"
self._bus.subscribe(z2m_wildcard, self._handle_z2m_message)
log.info("Sensor bridge started, subscribed to %s", z2m_wildcard)
def stop(self) -> None:
self._running = False
self._bus.disconnect()
log.info("Sensor bridge stopped")
def _handle_z2m_message(self, topic: str, payload: dict[str, Any]) -> None:
"""Handle a Zigbee2MQTT message, normalize and republish."""
# Topic format: zigbee2mqtt/<friendly_name>
# Ignore bridge status messages like zigbee2mqtt/bridge/...
parts = topic.split("/")
if len(parts) < 2:
return
# Skip bridge info topics
if parts[1] == "bridge":
return
friendly_name = parts[1]
sensor_cfg = self._registry.get_sensor_by_zigbee_name(friendly_name)
if sensor_cfg is None:
log.debug("Unknown zigbee device: %s", friendly_name)
return
normalized = normalize_zigbee_payload(payload)
for event_data in normalized:
event_type = event_data["event_type"]
# Create SensorEvent
event = SensorEvent(
sensor_id=sensor_cfg.id,
event_type=event_type,
state=event_data.get("state"),
value=event_data.get("value"),
timestamp=event_data["ts"],
source_protocol=SensorProtocol.ZIGBEE,
)
# Publish to internal bus
topic_out = Topics.sensor_event(sensor_cfg.id, event_type)
self._bus.publish(topic_out, event_data)
# Update DB state
self._persist_state(sensor_cfg.id, event_data)
def _persist_state(self, sensor_id: str, event_data: dict[str, Any]) -> None:
"""Write normalized event data to sensor_states table."""
try:
event_type = event_data["event_type"]
if "state" in event_data:
upsert_sensor_state(self._engine, sensor_id, event_type, event_data["state"])
elif "value" in event_data:
upsert_sensor_state(self._engine, sensor_id, event_type, event_data["value"])
elif "pct" in event_data:
upsert_sensor_state(self._engine, sensor_id, "battery", event_data["pct"])
# Always update last_seen
upsert_sensor_state(self._engine, sensor_id, "last_seen", event_data["ts"])
except Exception:
log.exception("Failed to persist state for %s", sensor_id)
def run_sensor_bridge(cfg: VigilarConfig) -> None:
"""Subprocess entry point for the sensor bridge."""
logging.basicConfig(level=getattr(logging, cfg.system.log_level, logging.INFO))
bridge = SensorBridge(cfg)
bridge.start()
# Block until signal
shutdown = False
def handle_signal(signum: int, frame: Any) -> None:
nonlocal shutdown
shutdown = True
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
try:
while not shutdown:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
bridge.stop()