332 lines
7.7 KiB
Python
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(),
|
|
} |