Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32f3fc754f | ||
|
|
9fd1b3976d | ||
|
|
e55d7a818e | ||
|
|
3754bbc380 | ||
|
|
a000fb6c44 |
Generated
+1
@@ -3,6 +3,7 @@
|
|||||||
<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 (ClimatController)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.14 (ClimatController)" jdkType="Python SDK" />
|
||||||
|
|||||||
+3
-3
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from .service import PrometheusMetricsService
|
||||||
@@ -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}")
|
||||||
+7
-11
@@ -16,7 +16,7 @@ templates:
|
|||||||
speed: 6
|
speed: 6
|
||||||
heater: on
|
heater: on
|
||||||
target_temp: 21
|
target_temp: 21
|
||||||
|
|
||||||
- time: "09:30"
|
- time: "09:30"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
@@ -27,12 +27,12 @@ templates:
|
|||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
speed: 6
|
speed: 6
|
||||||
|
|
||||||
- time: "22:45"
|
- time: "22:45"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
speed: 1
|
speed: 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
weekend:
|
weekend:
|
||||||
@@ -44,23 +44,18 @@ templates:
|
|||||||
speed: 6
|
speed: 6
|
||||||
heater: on
|
heater: on
|
||||||
target_temp: 21
|
target_temp: 21
|
||||||
|
|
||||||
- time: "10:30"
|
- time: "10:30"
|
||||||
action:
|
action:
|
||||||
type: auto
|
type: auto
|
||||||
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
|
||||||
speed: 6
|
speed: 6
|
||||||
|
|
||||||
- time: "22:45"
|
- time: "22:45"
|
||||||
action:
|
action:
|
||||||
type: set
|
type: set
|
||||||
@@ -78,4 +73,5 @@ days:
|
|||||||
fri: workday
|
fri: workday
|
||||||
|
|
||||||
sat: weekend
|
sat: weekend
|
||||||
sun: weekend
|
sun: weekend
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user