5 Commits
12 changed files with 836 additions and 14 deletions
+1
View File
@@ -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" />
+3 -3
View File
@@ -1,8 +1,8 @@
[version]
major = 1
minor = 2
patch = 1
minor = 3
patch = 0
[build]
date = "2026-09-20"
time = "11:28:15"
time = "14:17:42"
+27
View File
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path as FilePath
from app.qingping.service import QingpingService
from app.metrics import PrometheusMetricsService
from app.my_dataclasses import (
TION_MAC,
@@ -92,6 +93,8 @@ schedule_load_error: str | None = None
auto_controller: AutoController | None = None
auto_load_error: str | None = None
metrics_service: PrometheusMetricsService | None = None
# ----------------------------------------------------------------------
# Application lifecycle
# ----------------------------------------------------------------------
@@ -102,6 +105,7 @@ async def lifespan(app: FastAPI):
global schedule_load_error
global auto_controller
global auto_load_error
global metrics_service
await service.start()
await qingping_service.start()
@@ -167,9 +171,32 @@ async def lifespan(app: FastAPI):
except Exception as exc:
schedule_service = None
schedule_load_error = f"{type(exc).__name__}: {exc}"
try:
metrics_service = PrometheusMetricsService(
tion_service=service,
qingping_service=qingping_service,
schedule_service=schedule_service,
auto_controller=auto_controller,
output_path=(
"/var/lib/prometheus/"
"node-exporter/"
"climatcontroller.prom"
),
interval=5.0,
)
await metrics_service.start()
except Exception:
logging.getLogger(__name__).exception("Could not start Prometheus metrics service")
metrics_service = None
yield
finally:
if metrics_service is not None:
await metrics_service.stop()
if schedule_service is not None:
await schedule_service.stop()
View File
+49
View File
@@ -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"
+25
View File
@@ -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
View File
+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(),
}
+1
View File
@@ -0,0 +1 @@
from .service import PrometheusMetricsService
+309
View File
@@ -0,0 +1,309 @@
import asyncio
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
class PrometheusMetricsService:
def __init__(
self,
tion_service,
qingping_service,
schedule_service=None,
auto_controller=None,
output_path: str | Path = (
"/var/lib/prometheus/node-exporter/"
"climatcontroller.prom"
),
interval: float = 5.0,
):
self._tion = tion_service
self._qingping = qingping_service
self._schedule = schedule_service
self._auto = auto_controller
self._output_path = Path(output_path)
self._interval = interval
self._running = False
self._task = None
self._last_error: str | None = None
@property
def running(self) -> bool:
return self._running
@property
def last_error(self) -> str | None:
return self._last_error
async def start(self) -> None:
if self._running:
return
self._running = True
await self._write_metrics()
self._task = asyncio.create_task(
self._loop()
)
async def stop(self) -> None:
if not self._running:
return
self._running = False
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _loop(self) -> None:
while self._running:
await asyncio.sleep(
self._interval
)
try:
await self._write_metrics()
except Exception as exc:
self._last_error = f"{type(exc).__name__}: {exc}"
logger.exception("Prometheus metrics update failed")
async def _write_metrics(self) -> None:
lines: list[str] = []
# --------------------------------------------------
# Время последнего успешного обновления файла.
# Позволяет определить протухшие метрики.
# --------------------------------------------------
self._add_gauge(
lines,
"climat_metrics_timestamp_seconds",
time.time(),
"Unix timestamp of the last "
"ClimatController metrics update",
)
# --------------------------------------------------
# Tion
# --------------------------------------------------
self._add_gauge(
lines,
"climat_tion_online",
self._tion.online,
"Tion connection state",
)
tion_state = self._tion.state
if tion_state is not None:
self._add_gauge(
lines,
"climat_tion_power",
tion_state.power,
"Tion power state",
)
self._add_gauge(
lines,
"climat_tion_heater",
tion_state.heater,
"Tion heater enabled state",
)
self._add_gauge(
lines,
"climat_tion_heating",
tion_state.heating,
"Tion active heating state",
)
self._add_gauge(
lines,
"climat_tion_fan_speed",
tion_state.fan_speed,
"Tion fan speed",
)
self._add_gauge(
lines,
"climat_tion_in_temperature_celsius",
tion_state.in_temp,
"Tion inlet temperature",
)
self._add_gauge(
lines,
"climat_tion_out_temperature_celsius",
tion_state.out_temp,
"Tion outside temperature",
)
self._add_gauge(
lines,
"climat_tion_target_temperature_celsius",
tion_state.target_temp,
"Tion target temperature",
)
# --------------------------------------------------
# Qingping
# --------------------------------------------------
self._add_gauge(
lines,
"climat_qingping_online",
self._qingping.online,
"Qingping online state",
)
qingping_state = self._qingping.state
if qingping_state is not None:
self._add_gauge(
lines,
"climat_qingping_mqtt_connected",
qingping_state.mqtt_connected,
"Qingping MQTT connection state",
)
self._add_gauge(
lines,
"climat_qingping_temperature_celsius",
qingping_state.temperature,
"Qingping temperature",
)
self._add_gauge(
lines,
"climat_qingping_humidity_percent",
qingping_state.humidity,
"Qingping relative humidity",
)
self._add_gauge(
lines,
"climat_qingping_co2_ppm",
qingping_state.co2,
"Qingping CO2 concentration",
)
self._add_gauge(
lines,
"climat_qingping_pm25_ug_m3",
qingping_state.pm25,
"Qingping PM2.5 concentration",
)
self._add_gauge(
lines,
"climat_qingping_pm10_ug_m3",
qingping_state.pm10,
"Qingping PM10 concentration",
)
self._add_gauge(
lines,
"climat_qingping_battery_percent",
qingping_state.battery,
"Qingping battery level",
)
# --------------------------------------------------
# Schedule / AUTO
# --------------------------------------------------
if self._schedule is not None:
self._add_gauge(
lines,
"climat_schedule_paused",
self._schedule.paused,
"Schedule manual pause state",
)
try:
resolution = (
self._schedule.resolve()
)
self._add_gauge(
lines,
"climat_auto_active",
resolution.auto_active,
"AUTO schedule state",
)
except Exception:
logger.exception(
"Schedule metrics resolve failed"
)
# --------------------------------------------------
# Atomic write
# --------------------------------------------------
self._output_path.parent.mkdir(
parents=True,
exist_ok=True,
)
temp_path = (
self._output_path.with_suffix(
self._output_path.suffix
+ ".tmp"
)
)
temp_path.write_text(
"\n".join(lines) + "\n",
encoding="utf-8",
)
temp_path.replace(
self._output_path
)
self._last_error = None
@staticmethod
def _add_gauge(
lines: list[str],
name: str,
value,
help_text: str,
) -> None:
if value is None:
return
if isinstance(value, bool):
value = 1 if value else 0
lines.append(f"# HELP {name} {help_text}")
lines.append(f"# TYPE {name} gauge")
lines.append(f"{name} {value}")
+1 -5
View File
@@ -51,11 +51,6 @@ templates:
speed: 2
target_temp: 25
- time: "19:02"
action:
type: set
speed: 6
- time: "22:00"
action:
type: set
@@ -79,3 +74,4 @@ days:
sat: weekend
sun: weekend
+82
View File
@@ -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())