Files
ClimatController/app/exhaust/service.py
T

332 lines
7.7 KiB
Python

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(),
}