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>
95 lines
3.1 KiB
Python
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
|