1 Commits
5 changed files with 347 additions and 14 deletions
+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()
+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