from __future__ import annotations import json import logging import re import threading from ha_deconz_bridge.events import HaCommandEvent from ha_deconz_bridge.lights import LightEntitySpec, VirtualLightSpec from ha_deconz_bridge.settings import Settings from ha_deconz_bridge.state import VirtualLightState from ha_deconz_bridge.translate import ha_payload_from_state class HomeAssistantBridge: def __init__(self, settings: Settings, *, mqtt_client=None) -> None: if mqtt_client is None: import paho.mqtt.client as mqtt mqtt_client = mqtt.Client() self.settings = settings self._mqtt = mqtt_client if settings.mqtt_user is not None: self._mqtt.username_pw_set(settings.mqtt_user, settings.mqtt_password) self._on_command = None self._connected = threading.Event() self._connect_result: int | None = None self._light_name_by_object_id: dict[str, str] = {} self._command_topics: set[str] = set() def start(self, *, on_command) -> None: self._on_command = on_command self._connected.clear() self._connect_result = None self._mqtt.on_connect = self._handle_connect self._mqtt.on_message = self._handle_message self._mqtt.connect(self.settings.mqtt_host, self.settings.mqtt_port, 60) self._mqtt.loop_start() if not self._connected.wait(timeout=5.0): raise RuntimeError("MQTT connection did not become ready.") if self._connect_result not in {0, None}: raise RuntimeError(f"MQTT connection failed with result {self._connect_result}.") def stop(self) -> None: try: self._mqtt.loop_stop() finally: self._mqtt.disconnect() def publish_discovery(self, spec: VirtualLightSpec | LightEntitySpec) -> None: object_id = self.object_id(spec.name) self._light_name_by_object_id[object_id] = spec.name command_topic = self.command_topic(spec.name) self._command_topics.add(command_topic) payload: dict[str, object] = { "name": spec.name, "unique_id": object_id, "device": { "identifiers": [f"ha-deconz-bridge:{object_id}"], "name": f"HA deCONZ Bridge: {spec.name}", "manufacturer": "ha-deconz-bridge", "model": "Virtual Light", }, "schema": "json", "command_topic": command_topic, "state_topic": self.state_topic(spec.name), "supported_color_modes": list(spec.supported_color_modes), } if spec.exposed_mireds is not None: payload["min_mireds"] = spec.exposed_mireds[0] payload["max_mireds"] = spec.exposed_mireds[1] topic = f"{self.settings.mqtt_discovery_prefix}/light/{object_id}/config" self._mqtt.publish(topic, json.dumps(payload), retain=True) self._mqtt.subscribe(command_topic) logging.info("Published discovery for %s on %s", spec.name, topic) def publish_state(self, spec: VirtualLightSpec | LightEntitySpec, state: VirtualLightState) -> None: payload = ha_payload_from_state(state) self._mqtt.publish(self.state_topic(spec.name), json.dumps(payload), retain=True) logging.info("Published state for %s: %s", spec.name, payload) def command_topic(self, light_name: str) -> str: return f"{self.settings.mqtt_topic_prefix}/light/{self.object_id(light_name)}/set" def state_topic(self, light_name: str) -> str: return f"{self.settings.mqtt_topic_prefix}/light/{self.object_id(light_name)}/state" @staticmethod def object_id(light_name: str) -> str: sanitized = re.sub(r"[^a-zA-Z0-9_-]+", "_", light_name.strip()) sanitized = re.sub(r"_+", "_", sanitized).strip("_") if not sanitized: raise ValueError("Light name produces an empty MQTT object_id.") return sanitized def _handle_connect(self, client, userdata, flags, reason_code, properties=None) -> None: self._connect_result = int(reason_code) if self._connect_result == 0: logging.info("Connected to MQTT broker at %s:%s", self.settings.mqtt_host, self.settings.mqtt_port) for topic in sorted(self._command_topics): self._mqtt.subscribe(topic) logging.info("Subscribed to %s", topic) else: logging.error("MQTT connect failed with result %s", self._connect_result) self._connected.set() def _handle_message(self, client, userdata, message) -> None: if self._on_command is None: return object_id = message.topic.split("/")[-2] light_name = self._light_name_by_object_id.get(object_id, object_id) payload = json.loads(message.payload.decode()) logging.info("Received MQTT command for %s on %s: %s", light_name, message.topic, payload) self._on_command(HaCommandEvent.from_payload(light_name, payload))