import json import logging from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional from mqtt_home.topic_matcher import match_topic, extract_device_id, build_command_topic, extract_state_value logger = logging.getLogger(__name__) @dataclass class BrokerDevice: """In-memory broker device, not persisted to DB""" id: str # "broker:{device_id}" e.g. "broker:fire" name: str # Human-readable name, e.g. "fire" type: str # Device type from rule, e.g. "switch" protocol: str = "topic_rule" mqtt_topic: str = "" # The matched topic, e.g. "home/fire" command_topic: Optional[str] = None # Built from template, e.g. "home/fire/set" state: Optional[str] = None # Latest payload (or extracted value) is_online: bool = False last_seen: Optional[datetime] = None rule_id: int = 0 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) class BrokerDeviceRegistry: """In-memory registry for devices discovered via topic format rules""" def __init__(self): self._devices: dict[str, BrokerDevice] = {} def update_or_create(self, topic: str, payload: str, rule) -> Optional[BrokerDevice]: """Try to match topic against rule pattern, create/update device if matched. Args: topic: The MQTT topic that received a message payload: The message payload string rule: TopicFormatRule instance (SQLAlchemy model or dict-like) Returns: Updated or created BrokerDevice, or None if topic doesn't match rule """ # 1. Check if topic matches the rule's pattern pattern = rule.topic_pattern if not match_topic(topic, pattern): return None # 2. Extract device_id from topic device_id_raw = extract_device_id(topic, pattern) if not device_id_raw: return None device_id = f"broker:{device_id_raw}" # 3. Extract state value from payload state = extract_state_value(payload, rule.state_value_path) # 4. Build command topic from template command_topic = build_command_topic(rule.command_template, device_id_raw) now = datetime.now(timezone.utc) # 5. Update existing or create new if device_id in self._devices: device = self._devices[device_id] device.state = state device.is_online = True device.last_seen = now device.updated_at = now # Update command_topic in case rule changed if command_topic: device.command_topic = command_topic else: device = BrokerDevice( id=device_id, name=device_id_raw, type=rule.device_type, mqtt_topic=topic, command_topic=command_topic, state=state, is_online=True, last_seen=now, rule_id=rule.id, ) self._devices[device_id] = device logger.info("Broker device discovered: %s (topic=%s, rule=%d)", device_id, topic, rule.id) return device def get_all(self) -> list[BrokerDevice]: return list(self._devices.values()) def get(self, device_id: str) -> Optional[BrokerDevice]: return self._devices.get(device_id) def remove_by_rule(self, rule_id: int) -> list[str]: """Remove all devices associated with a rule. Returns list of removed device IDs.""" to_remove = [did for did, d in self._devices.items() if d.rule_id == rule_id] for did in to_remove: del self._devices[did] return to_remove def clear(self): self._devices.clear()