work: добавил каркас под управление вытяжкой!
This commit is contained in:
Generated
+1
@@ -3,6 +3,7 @@
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.14 (ClimatController)" jdkType="Python SDK" />
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user