""" Device Manager for IoT Bridge Manages dynamic addition/removal of devices and MQTT subscriptions """ from datetime import datetime import structlog from core.session_detector import SessionDetector from parsers.shelly_parser import ShellyParser logger = structlog.get_logger() class DeviceManager: """ Manages IoT devices, session detectors, and MQTT subscriptions dynamically. Handles: - Adding new devices (creates detector + subscribes to MQTT) - Updating devices (updates detector parameters) - Removing devices (closes session + unsubscribes) - Routing MQTT messages to correct detectors - Tracking device online/offline status """ def __init__(self, mqtt_client, event_queue, parser=None, status_monitor=None): """ Initialize Device Manager. Args: mqtt_client: MQTTClient instance for managing subscriptions event_queue: EventQueue instance for event handling parser: Message parser (default: ShellyParser) status_monitor: DeviceStatusMonitor instance for tracking online/offline """ self.mqtt_client = mqtt_client self.event_queue = event_queue self.parser = parser or ShellyParser() self.status_monitor = status_monitor # Device tracking self.session_detectors: dict[str, SessionDetector] = {} self.device_map: dict[str, str] = {} # topic -> device_id logger.info("device_manager_initialized") def apply_config(self, config): """ Apply new configuration: add/update/remove devices. Args: config: BridgeConfig instance from api.models """ from api.models import BridgeConfig if not isinstance(config, BridgeConfig): raise ValueError("config must be a BridgeConfig instance") # Get current and new device IDs current_device_ids = set(self.session_detectors.keys()) new_device_ids = {d.device_id for d in config.devices} # Devices to add to_add = new_device_ids - current_device_ids # Devices to remove to_remove = current_device_ids - new_device_ids # Devices to potentially update to_check = current_device_ids & new_device_ids logger.info("config_diff", add=len(to_add), remove=len(to_remove), check=len(to_check)) # Remove old devices for device_id in to_remove: self._remove_device(device_id) # Add new devices for device in config.devices: if device.device_id in to_add: self._add_device(device) # Check for updates in existing devices for device in config.devices: if device.device_id in to_check: self._update_device(device) logger.info( "config_applied", total_devices=len(self.session_detectors), subscriptions=len(self.device_map), ) def _add_device(self, device): """Add a new device: create detector + subscribe to MQTT.""" try: device_id = device.device_id # Create callback for this detector def event_callback(event): logger.info( "event_generated", event_type=event["event_type"], device_id=device_id, ) self.event_queue.enqueue(event) # Create session detector session_cfg = device.session_config detector = SessionDetector( device_id=device_id, machine_name=device.machine_name, standby_threshold_w=session_cfg.standby_threshold_w, working_threshold_w=session_cfg.working_threshold_w, start_debounce_s=session_cfg.start_debounce_s, stop_debounce_s=session_cfg.stop_debounce_s, message_timeout_s=session_cfg.message_timeout_s, heartbeat_interval_s=session_cfg.heartbeat_interval_s, event_callback=event_callback, ) self.session_detectors[device_id] = detector self.device_map[device.mqtt_topic] = device_id # Subscribe to MQTT topic self.mqtt_client.subscribe(device.mqtt_topic) logger.info( "device_added", device_id=device_id, machine_name=device.machine_name, topic=device.mqtt_topic, ) except Exception as e: logger.error("device_add_failed", device_id=device.device_id, error=str(e)) raise def _update_device(self, device): """ Update existing device configuration. Checks if session config changed and updates detector parameters. Note: MQTT topic changes require remove + add. """ try: device_id = device.device_id detector = self.session_detectors.get(device_id) if not detector: logger.warning("device_not_found_for_update", device_id=device_id) return # Check if anything changed session_cfg = device.session_config changed = False if detector.standby_threshold_w != session_cfg.standby_threshold_w: detector.standby_threshold_w = session_cfg.standby_threshold_w changed = True if detector.working_threshold_w != session_cfg.working_threshold_w: detector.working_threshold_w = session_cfg.working_threshold_w changed = True if detector.heartbeat_interval_s != session_cfg.heartbeat_interval_s: detector.heartbeat_interval_s = session_cfg.heartbeat_interval_s changed = True if detector.machine_name != device.machine_name: detector.machine_name = device.machine_name changed = True if changed: logger.info( "device_updated", device_id=device_id, standby_w=session_cfg.standby_threshold_w, working_w=session_cfg.working_threshold_w, ) else: logger.debug("device_unchanged", device_id=device_id) except Exception as e: logger.error("device_update_failed", device_id=device.device_id, error=str(e)) def _remove_device(self, device_id: str): """ Remove device: close active session + unsubscribe from MQTT. """ try: detector = self.session_detectors.get(device_id) if not detector: logger.warning("device_not_found_for_removal", device_id=device_id) return # Close active session if any if detector.current_session_id: logger.info( "closing_session_before_removal", device_id=device_id, session_id=detector.current_session_id[:8], ) detector._end_session("session_ended", datetime.utcnow()) # Triggers event # Find and remove topic mapping topic_to_remove = None for topic, dev_id in self.device_map.items(): if dev_id == device_id: topic_to_remove = topic break if topic_to_remove: # Unsubscribe from MQTT self.mqtt_client.unsubscribe(topic_to_remove) del self.device_map[topic_to_remove] # Remove detector del self.session_detectors[device_id] logger.info("device_removed", device_id=device_id, topic=topic_to_remove) except Exception as e: logger.error("device_remove_failed", device_id=device_id, error=str(e)) def route_message(self, topic: str, payload: dict): """ Route MQTT message to appropriate device detector. Args: topic: MQTT topic payload: Message payload (dict) """ device_id = self.device_map.get(topic) if not device_id: logger.warning("no_device_for_topic", topic=topic) return detector = self.session_detectors.get(device_id) if not detector: logger.warning("no_detector_for_device", device_id=device_id) return try: # Update device status (track last_seen for online/offline detection) if self.status_monitor: self.status_monitor.update_last_seen(device_id) # Parse message parsed = self.parser.parse_message(topic, payload) if parsed and parsed.get("apower") is not None: power_w = parsed["apower"] detector.process_power_measurement(power_w, datetime.utcnow()) except Exception as e: logger.error("message_routing_error", topic=topic, error=str(e)) def check_timeouts(self): """Check all detectors for timeouts.""" current_time = datetime.utcnow() for detector in self.session_detectors.values(): detector.check_timeout(current_time) def get_status(self) -> dict: """Get current device manager status.""" return { "device_count": len(self.session_detectors), "subscriptions": len(self.device_map), "devices": [ { "device_id": device_id, "state": detector.state, "session_active": detector.current_session_id is not None, } for device_id, detector in self.session_detectors.items() ], }