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}")