8 Commits
13 changed files with 838 additions and 16 deletions
+2 -1
View File
@@ -3,9 +3,10 @@
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/app" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.14 (ClimatController)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/TionController.iml" filepath="$PROJECT_DIR$/.idea/TionController.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/ClimatController.iml" filepath="$PROJECT_DIR$/.idea/ClimatController.iml" />
</modules> </modules>
</component> </component>
</project> </project>
+3 -3
View File
@@ -1,8 +1,8 @@
[version] [version]
major = 1 major = 1
minor = 2 minor = 3
patch = 1 patch = 0
[build] [build]
date = "2026-09-20" 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 fastapi.staticfiles import StaticFiles
from pathlib import Path as FilePath from pathlib import Path as FilePath
from app.qingping.service import QingpingService from app.qingping.service import QingpingService
from app.metrics import PrometheusMetricsService
from app.my_dataclasses import ( from app.my_dataclasses import (
TION_MAC, TION_MAC,
@@ -92,6 +93,8 @@ schedule_load_error: str | None = None
auto_controller: AutoController | None = None auto_controller: AutoController | None = None
auto_load_error: str | None = None auto_load_error: str | None = None
metrics_service: PrometheusMetricsService | None = None
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Application lifecycle # Application lifecycle
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -102,6 +105,7 @@ async def lifespan(app: FastAPI):
global schedule_load_error global schedule_load_error
global auto_controller global auto_controller
global auto_load_error global auto_load_error
global metrics_service
await service.start() await service.start()
await qingping_service.start() await qingping_service.start()
@@ -167,9 +171,32 @@ async def lifespan(app: FastAPI):
except Exception as exc: except Exception as exc:
schedule_service = None schedule_service = None
schedule_load_error = f"{type(exc).__name__}: {exc}" 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 yield
finally: finally:
if metrics_service is not None:
await metrics_service.stop()
if schedule_service is not None: if schedule_service is not None:
await schedule_service.stop() 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 speed: 2
target_temp: 25 target_temp: 25
- time: "19:02"
action:
type: set
speed: 6
- time: "22:00" - time: "22:00"
action: action:
type: set type: set
@@ -79,3 +74,4 @@ days:
sat: weekend sat: weekend
sun: 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())