diff --git a/.idea/ClimatController.iml b/.idea/ClimatController.iml index dcdb726..1b03116 100644 --- a/.idea/ClimatController.iml +++ b/.idea/ClimatController.iml @@ -3,6 +3,7 @@ + diff --git a/app/exhaust/__init__.py b/app/exhaust/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/exhaust/config.py b/app/exhaust/config.py new file mode 100644 index 0000000..6fc4868 --- /dev/null +++ b/app/exhaust/config.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ExhaustConfig: + mqtt_host: str + mqtt_port: int = 1883 + + # Topic устройства в Tasmota + topic: str = "exhaust" + + # Длительность виртуального нажатия кнопки + pulse_ms: int = 300 + + # Соответствие реле функциям Elica. + # Если после приезда платы каналы окажутся другими, + # меняем только конфигурацию. + power_relay: int = 1 + speed_relay: int = 2 + light_relay: int = 3 + timer_relay: int = 4 + + @property + def command_prefix(self) -> str: + return f"cmnd/{self.topic}" + + @property + def state_prefix(self) -> str: + return f"stat/{self.topic}" + + @property + def telemetry_prefix(self) -> str: + return f"tele/{self.topic}" + + @property + def lwt_topic(self) -> str: + return f"{self.telemetry_prefix}/LWT" + + @property + def state_topic(self) -> str: + return f"{self.telemetry_prefix}/STATE" + + @property + def status2_topic(self) -> str: + return f"{self.state_prefix}/STATUS2" + + @property + def result_topic(self) -> str: + return f"{self.state_prefix}/RESULT" \ No newline at end of file diff --git a/app/exhaust/models.py b/app/exhaust/models.py new file mode 100644 index 0000000..20a9107 --- /dev/null +++ b/app/exhaust/models.py @@ -0,0 +1,25 @@ +from dataclasses import asdict, dataclass +from datetime import datetime + + +@dataclass(frozen=True, slots=True) +class ExhaustState: + mqtt_connected: bool = False + device_online: bool = False + + last_message_at: datetime | None = None + + firmware: str | None = None + uptime_seconds: int | None = None + + wifi_rssi: int | None = None + + def to_dict(self) -> dict: + result = asdict(self) + + if result["last_message_at"] is not None: + result["last_message_at"] = ( + result["last_message_at"].isoformat() + ) + + return result \ No newline at end of file diff --git a/app/exhaust/protocol.py b/app/exhaust/protocol.py new file mode 100644 index 0000000..e69de29 diff --git a/app/exhaust/service.py b/app/exhaust/service.py new file mode 100644 index 0000000..43dc165 --- /dev/null +++ b/app/exhaust/service.py @@ -0,0 +1,332 @@ +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(), + } \ No newline at end of file diff --git a/tests/test_exhaust_service.py b/tests/test_exhaust_service.py new file mode 100644 index 0000000..58fafdd --- /dev/null +++ b/tests/test_exhaust_service.py @@ -0,0 +1,82 @@ +# from app.exhaust.config import ExhaustConfig +# from app.exhaust.service import ExhaustService +# +# +# config = ExhaustConfig( +# mqtt_host="192.168.7.100", +# mqtt_port=1883, +# topic="exhaust", +# ) +# +# service = ExhaustService(config) +# +# +# print("Configuration:") +# print("MQTT:", service.config.mqtt_host) +# print("Port:", service.config.mqtt_port) +# print("Topic:", service.config.topic) +# print("Command:", service.config.command_prefix) +# print("State:", service.config.state_prefix) +# print("Telemetry:", service.config.telemetry_prefix) +# print("LWT:", service.config.lwt_topic) +# +# +# print("\nInitial:") +# print(service.running) +# print(service.state.to_dict()) +# +# +# service.start() +# +# print("\nAfter start:") +# print(service.running) +# print(service.state.to_dict()) +# +# +# service.stop() +# +# print("\nAfter stop:") +# print(service.running) +# print(service.state.to_dict()) + +import asyncio + +from app.exhaust.config import ExhaustConfig +from app.exhaust.service import ExhaustService + + +async def main(): + config = ExhaustConfig( + mqtt_host="192.168.7.100", + mqtt_port=1883, + topic="exhaust", + ) + + service = ExhaustService(config) + + print("Initial:") + print(service.running) + print(service.state.to_dict()) + + await service.start() + + print("\nStarted. Waiting for MQTT...") + await asyncio.sleep(2) + + print("\nRequesting Tasmota status...") + service.request_status() + + await asyncio.sleep(2) + + print(service.running) + print(service.state.to_dict()) + + await service.stop() + + print("\nAfter stop:") + print(service.running) + print(service.state.to_dict()) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file