diff --git a/.idea/TionController.iml b/.idea/ClimatController.iml
similarity index 80%
rename from .idea/TionController.iml
rename to .idea/ClimatController.iml
index ad3df1f..dcdb726 100644
--- a/.idea/TionController.iml
+++ b/.idea/ClimatController.iml
@@ -5,7 +5,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
index f493d7d..c541f65 100644
--- a/.idea/modules.xml
+++ b/.idea/modules.xml
@@ -2,7 +2,7 @@
-
+
\ No newline at end of file
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
diff --git a/config/schedule.yaml b/config/schedule.yaml
index 25a4576..4276d05 100644
--- a/config/schedule.yaml
+++ b/config/schedule.yaml
@@ -16,7 +16,7 @@ templates:
speed: 6
heater: on
target_temp: 21
-
+
- time: "09:30"
action:
type: auto
@@ -27,12 +27,12 @@ templates:
action:
type: set
speed: 6
-
+
- time: "22:45"
action:
type: set
speed: 1
-
+
weekend:
@@ -44,23 +44,18 @@ templates:
speed: 6
heater: on
target_temp: 21
-
+
- time: "10:30"
action:
type: auto
speed: 2
target_temp: 25
- - time: "19:02"
- action:
- type: set
- speed: 6
-
- time: "22:00"
action:
type: set
speed: 6
-
+
- time: "22:45"
action:
type: set
@@ -78,4 +73,5 @@ days:
fri: workday
sat: weekend
- sun: weekend
\ No newline at end of file
+ sun: weekend
+