import logging import json import logging from dataclasses import replace from datetime import datetime, timezone import paho.mqtt.client as mqtt from .config import ExhaustConfig from .models import ExhaustState logger = logging.getLogger(__name__) class ExhaustService: """ Сервис управления вытяжкой. Пока не работает с MQTT и ESP. На данном этапе только хранит состояние. """ def __init__(self, config: ExhaustConfig): self._config = config self._state = ExhaustState() self._running = False self._client: mqtt.Client | None = None @property def config(self) -> ExhaustConfig: return self._config @property def state(self) -> ExhaustState: return self._state @property def running(self) -> bool: return self._running async def start(self) -> None: if self._running: return client = mqtt.Client( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id="climatcontroller-exhaust", ) client.on_connect = self._on_connect client.on_disconnect = self._on_disconnect client.on_message = self._on_message client.reconnect_delay_set( min_delay=1, max_delay=30, ) self._client = client self._running = True client.connect_async( self._config.mqtt_host, self._config.mqtt_port, keepalive=60, ) client.loop_start() async def stop(self) -> None: if not self._running: return self._running = False if self._client is not None: self._client.disconnect() self._client.loop_stop() self._client = None self._state = replace( self._state, mqtt_connected=False, device_online=False, ) def _on_connect( self, _client, _userdata, _flags, reason_code, _properties=None, ) -> None: if reason_code != 0: logger.error( "Exhaust MQTT connection failed: %s", reason_code, ) return logger.info("Exhaust MQTT connected") _client.subscribe(self._config.lwt_topic) _client.subscribe(self._config.state_topic) _client.subscribe(self._config.status2_topic) _client.subscribe(self._config.result_topic) self._state = replace( self._state, mqtt_connected=True, ) def _on_disconnect( self, _client, _userdata, _disconnect_flags, reason_code, _properties=None, ) -> None: logger.warning( "Exhaust MQTT disconnected: %s", reason_code, ) self._state = replace( self._state, mqtt_connected=False, device_online=False, ) def _on_message( self, _client, _userdata, message, ) -> None: if message.topic == self._config.lwt_topic: self._handle_lwt(message) return if message.topic == self._config.state_topic: self._handle_state(message) return if message.topic == self._config.status2_topic: self._handle_status2(message) return if message.topic == self._config.result_topic: self._handle_result(message) return def _handle_lwt(self, message) -> None: payload = ( message.payload .decode("utf-8") .strip() ) now = datetime.now(timezone.utc) if payload.lower() == "online": device_online = True elif payload.lower() == "offline": device_online = False else: logger.warning( "Unknown Exhaust LWT payload: %r", payload, ) return self._state = replace( self._state, device_online=device_online, last_message_at=now, ) def _handle_state(self, message) -> None: import json try: payload = json.loads( message.payload.decode("utf-8") ) except (UnicodeDecodeError, json.JSONDecodeError): logger.warning( "Invalid Exhaust STATE payload" ) return now = datetime.now(timezone.utc) wifi = payload.get("Wifi") or {} self._state = replace( self._state, last_message_at=now, uptime_seconds=payload.get("UptimeSec"), wifi_rssi=wifi.get("RSSI"), ) def request_status(self) -> None: self._publish_command( "Status", "2", ) def _handle_status2(self, message) -> None: try: payload = json.loads( message.payload.decode("utf-8") ) except (UnicodeDecodeError, json.JSONDecodeError): logger.warning( "Invalid Exhaust STATUS2 payload" ) return firmware_info = payload.get("StatusFWR") or {} firmware = firmware_info.get("Version") now = datetime.now(timezone.utc) self._state = replace( self._state, firmware=firmware, last_message_at=now, ) def _publish_command( self, command: str, payload: str, ) -> None: if not self._running: raise RuntimeError("Exhaust service is not running") if ( self._client is None or not self._state.mqtt_connected ): raise RuntimeError("Exhaust MQTT is not connected") topic = ( f"{self._config.command_prefix}/" f"{command}" ) result = self._client.publish( topic, payload, retain=False, ) if result.rc != mqtt.MQTT_ERR_SUCCESS: raise RuntimeError(f"MQTT publish failed: {result.rc}") def _press_relay( self, relay: int, ) -> None: self._publish_command( f"TimedPower{relay}", f"{self._config.pulse_ms},on", ) def press_power(self) -> None: self._press_relay( self._config.power_relay ) def press_speed(self) -> None: self._press_relay( self._config.speed_relay ) def press_light(self) -> None: self._press_relay( self._config.light_relay ) def press_timer(self) -> None: self._press_relay( self._config.timer_relay ) def _handle_result(self,message) -> None: try: payload = json.loads( message.payload.decode("utf-8") ) except ( UnicodeDecodeError, json.JSONDecodeError, ): logger.warning( "Invalid Exhaust RESULT payload" ) return self._state = replace( self._state, last_message_at=datetime.now( timezone.utc ), ) logger.debug("Exhaust Tasmota result: %s",payload) def status(self) -> dict: return { "running": self._running, "topic": self._config.topic, "pulse_ms": self._config.pulse_ms, **self._state.to_dict(), }