work: добавил каркас под управление вытяжкой!

This commit is contained in:
2026-09-20 18:24:17 +03:00
parent 9fd1b3976d
commit 32f3fc754f
7 changed files with 489 additions and 0 deletions
+332
View File
@@ -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(),
}