Files
vigilar/vigilar/events/state.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

95 lines
3.1 KiB
Python

"""Arm state finite state machine."""
import hashlib
import hmac
import logging
import time
from sqlalchemy.engine import Engine
from vigilar.config import VigilarConfig
from vigilar.constants import ArmState, EventType, Severity, Topics
from vigilar.storage.queries import get_current_arm_state, insert_arm_state, insert_event
log = logging.getLogger(__name__)
class ArmStateFSM:
"""Manages DISARMED / ARMED_HOME / ARMED_AWAY state transitions."""
def __init__(self, engine: Engine, config: VigilarConfig):
self._engine = engine
self._pin_hash = config.system.arm_pin_hash
self._state = ArmState.DISARMED
self._bus = None
self._load_initial_state()
def _load_initial_state(self) -> None:
"""Load the most recent arm state from the database."""
stored = get_current_arm_state(self._engine)
if stored and stored in ArmState.__members__:
self._state = ArmState(stored)
log.info("Loaded arm state from DB: %s", self._state)
else:
self._state = ArmState.DISARMED
log.info("No stored arm state, defaulting to DISARMED")
def set_bus(self, bus: object) -> None:
"""Attach a MessageBus for publishing state changes."""
self._bus = bus
@property
def state(self) -> ArmState:
return self._state
def verify_pin(self, pin: str) -> bool:
"""Verify a PIN against the stored hash using HMAC comparison."""
if not self._pin_hash:
# No PIN configured — allow all transitions
return True
candidate = hashlib.sha256(pin.encode()).hexdigest()
return hmac.compare_digest(candidate, self._pin_hash)
def transition(
self,
new_state: ArmState,
pin: str = "",
triggered_by: str = "system",
) -> bool:
"""Attempt a state transition. Returns True if successful."""
if new_state == self._state:
return True
# PIN required for arming from disarmed or disarming from armed
if self._pin_hash and not self.verify_pin(pin):
log.warning("Arm state change rejected: bad PIN (by %s)", triggered_by)
return False
old_state = self._state
self._state = new_state
# Log to database
pin_hash = hashlib.sha256(pin.encode()).hexdigest() if pin else None
insert_arm_state(self._engine, new_state.value, triggered_by, pin_hash)
# Log event
insert_event(
self._engine,
event_type=EventType.ARM_STATE_CHANGED,
severity=Severity.INFO,
source_id="system",
payload={"old_state": old_state.value, "new_state": new_state.value},
)
# Publish to MQTT
if self._bus is not None:
self._bus.publish(Topics.SYSTEM_ARM_STATE, {
"ts": int(time.time() * 1000),
"state": new_state.value,
"old_state": old_state.value,
"triggered_by": triggered_by,
})
log.info("Arm state: %s -> %s (by %s)", old_state, new_state, triggered_by)
return True