diff --git a/Version.toml b/Version.toml index d684908..1f850b7 100644 --- a/Version.toml +++ b/Version.toml @@ -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" diff --git a/app/api.py b/app/api.py index 1a10fd7..d6fbf21 100644 --- a/app/api.py +++ b/app/api.py @@ -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() diff --git a/app/metrics/__init__.py b/app/metrics/__init__.py new file mode 100644 index 0000000..c131c38 --- /dev/null +++ b/app/metrics/__init__.py @@ -0,0 +1 @@ +from .service import PrometheusMetricsService \ No newline at end of file diff --git a/app/metrics/service.py b/app/metrics/service.py new file mode 100644 index 0000000..41b6dc9 --- /dev/null +++ b/app/metrics/service.py @@ -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}") \ No newline at end of file