Fix
This commit is contained in:
@@ -1,33 +1,15 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
FROM python:3.11-slim
|
||||||
|
|
||||||
FROM python:3.12-slim AS base
|
|
||||||
|
|
||||||
# Меньше мусора в логах python
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
|
||||||
|
|
||||||
# Непривилегированный пользователь
|
|
||||||
RUN groupadd --system --gid 1001 app \
|
|
||||||
&& useradd --system --uid 1001 --gid app --create-home app
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Ставим зависимости отдельным слоем (кэшируется)
|
# Установка зависимостей
|
||||||
COPY requirements.txt ./
|
RUN pip install --no-cache-dir requests prometheus-client
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Копируем код
|
# Копирование кода
|
||||||
COPY --chown=app:app zont_exporter.py ./
|
COPY exporter.py .
|
||||||
|
|
||||||
USER app
|
# Экспорт порта Prometheus
|
||||||
|
EXPOSE 9101
|
||||||
|
|
||||||
EXPOSE 9877
|
# Запуск
|
||||||
|
CMD ["python", "exporter.py"]
|
||||||
# Healthcheck: сам /metrics должен отвечать 200
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request,sys; \
|
|
||||||
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9877/metrics', timeout=4).status==200 else 1)"
|
|
||||||
|
|
||||||
ENTRYPOINT ["python", "zont_exporter.py"]
|
|
||||||
+130
-579
@@ -1,4 +1,3 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
"""
|
||||||
ZONT API Prometheus Exporter
|
ZONT API Prometheus Exporter
|
||||||
|
|
||||||
@@ -17,608 +16,160 @@ Mega SX, ZTC и др.) в формате Prometheus.
|
|||||||
EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9877)
|
EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9877)
|
||||||
LOG_LEVEL — DEBUG/INFO/WARNING/ERROR (по умолчанию INFO)
|
LOG_LEVEL — DEBUG/INFO/WARNING/ERROR (по умолчанию INFO)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
import signal
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from prometheus_client import start_http_server, Gauge, Info
|
from prometheus_client import start_http_server, Gauge
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Настройка логирования
|
||||||
# Настройки (через переменные окружения)
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ZONT_LOGIN = os.environ.get("ZONT_LOGIN", "demo")
|
|
||||||
ZONT_PASSWORD = os.environ.get("ZONT_PASSWORD", "demo")
|
|
||||||
ZONT_DEVICE_ID = int(os.environ.get("ZONT_DEVICE_ID", "0")) # 0 = авто-выбор
|
|
||||||
ZONT_EMAIL = os.environ.get("ZONT_EMAIL", "exporter@example.com")
|
|
||||||
ZONT_TOKEN = os.environ.get("ZONT_TOKEN") or None
|
|
||||||
ZONT_POLL_INTERVAL = int(os.environ.get("ZONT_POLL_INTERVAL", "60"))
|
|
||||||
EXPORTER_PORT = int(os.environ.get("EXPORTER_PORT", "9877"))
|
|
||||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
|
|
||||||
|
|
||||||
|
# Конфигурация из переменных окружения
|
||||||
|
ZONT_LOGIN = os.getenv('ZONT_LOGIN')
|
||||||
|
ZONT_PASSWORD = os.getenv('ZONT_PASSWORD')
|
||||||
|
ZONT_CLIENT = os.getenv('ZONT_CLIENT', 'my_exporter@example.com')
|
||||||
|
DEVICE_ID = int(os.getenv('ZONT_DEVICE_ID'))
|
||||||
API_BASE = "https://my.zont.online/api"
|
API_BASE = "https://my.zont.online/api"
|
||||||
|
SCRAPE_INTERVAL = int(os.getenv('SCRAPE_INTERVAL', 60))
|
||||||
|
|
||||||
logging.basicConfig(
|
# Определение метрик Prometheus
|
||||||
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
TEMP_GAUGE = Gauge('zont_temperature_celsius', 'Current temperature', ['device_id', 'sensor_name', 'sensor_type'])
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
BOILER_WORK = Gauge('zont_boiler_work_seconds', 'Boiler work time in last minute', ['device_id'])
|
||||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
TARGET_TEMP = Gauge('zont_target_temperature_celsius', 'Target temperature', ['device_id'])
|
||||||
)
|
|
||||||
log = logging.getLogger("zont-exporter")
|
|
||||||
|
|
||||||
|
# Глобальная переменная для токена
|
||||||
|
auth_token = None
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def get_auth_token():
|
||||||
# Клиент ZONT API
|
"""Получает токен аутентификации ZONT API."""
|
||||||
# ---------------------------------------------------------------------------
|
global auth_token
|
||||||
class ZontClient:
|
try:
|
||||||
"""Тонкая обёртка над REST API ZONT с автоматическим переполучением токена."""
|
resp = requests.post(
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
login: str,
|
|
||||||
password: str,
|
|
||||||
client_email: str,
|
|
||||||
token: Optional[str] = None,
|
|
||||||
):
|
|
||||||
self.login = login
|
|
||||||
self.password = password
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.session.headers.update({
|
|
||||||
"X-ZONT-Client": client_email,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
})
|
|
||||||
self.session.auth = (login, password)
|
|
||||||
self.token: Optional[str] = token
|
|
||||||
|
|
||||||
# -- Аутентификация ----------------------------------------------------
|
|
||||||
def get_authtoken(self, client_name: str = "prometheus-exporter") -> str:
|
|
||||||
"""POST /get_authtoken — получаем токен по логину/паролю."""
|
|
||||||
r = self.session.post(
|
|
||||||
f"{API_BASE}/get_authtoken",
|
f"{API_BASE}/get_authtoken",
|
||||||
json={"client_name": client_name},
|
auth=(ZONT_LOGIN, ZONT_PASSWORD),
|
||||||
timeout=20,
|
headers={'X-ZONT-Client': ZONT_CLIENT},
|
||||||
|
json={"client_name": "Prometheus Exporter"},
|
||||||
|
timeout=10
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = r.json()
|
data = resp.json()
|
||||||
if not data.get("ok"):
|
if data.get('ok'):
|
||||||
raise RuntimeError(f"get_authtoken failed: {data}")
|
auth_token = data.get('token')
|
||||||
self.token = data["token"]
|
logging.info("Successfully obtained ZONT auth token.")
|
||||||
log.info("Получен новый auth-токен")
|
else:
|
||||||
return self.token
|
logging.error(f"Failed to get auth token: {data}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Exception while getting auth token: {e}")
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
def fetch_last_value(dta_list):
|
||||||
h = {}
|
"""Извлекает последнее значение из Delta-time array."""
|
||||||
if self.token:
|
if not dta_list or not isinstance(dta_list, list):
|
||||||
h["X-ZONT-Token"] = self.token
|
|
||||||
return h
|
|
||||||
|
|
||||||
# -- Универсальный POST -------------------------------------------------
|
|
||||||
def _post(self, method: str, payload: dict) -> dict:
|
|
||||||
"""POST с автоматическим переполучением токена при 403."""
|
|
||||||
if not self.token:
|
|
||||||
self.get_authtoken()
|
|
||||||
|
|
||||||
url = f"{API_BASE}/{method}"
|
|
||||||
for attempt in (1, 2):
|
|
||||||
r = self.session.post(
|
|
||||||
url, json=payload, headers=self._headers(), timeout=20
|
|
||||||
)
|
|
||||||
if r.status_code == 403 and attempt == 1:
|
|
||||||
log.warning("Токен отозван, получаю новый...")
|
|
||||||
self.get_authtoken()
|
|
||||||
continue
|
|
||||||
r.raise_for_status()
|
|
||||||
data = r.json()
|
|
||||||
if not data.get("ok"):
|
|
||||||
raise RuntimeError(f"{method} error: {data}")
|
|
||||||
return data
|
|
||||||
raise RuntimeError(f"{method}: не удалось выполнить запрос")
|
|
||||||
|
|
||||||
# -- Высокоуровневые методы --------------------------------------------
|
|
||||||
def devices(self, load_io: bool = True) -> list:
|
|
||||||
"""POST /devices — список устройств с состояниями."""
|
|
||||||
return self._post("devices", {"load_io": load_io}).get("devices", [])
|
|
||||||
|
|
||||||
def load_data(
|
|
||||||
self,
|
|
||||||
device_id: int,
|
|
||||||
data_types: list,
|
|
||||||
mintime: Optional[int] = None,
|
|
||||||
maxtime: Optional[int] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""POST /load_data — история данных."""
|
|
||||||
req: dict = {"device_id": device_id, "data_types": data_types}
|
|
||||||
if mintime is not None:
|
|
||||||
req["mintime"] = mintime
|
|
||||||
if maxtime is not None:
|
|
||||||
req["maxtime"] = maxtime
|
|
||||||
return self._post("load_data", {"requests": [req]})["responses"][0]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Метрики Prometheus
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# --- Общие ---
|
|
||||||
g_device_info = Info(
|
|
||||||
"zont_device",
|
|
||||||
"Информация об устройстве ZONT",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Термостат / отопление ---
|
|
||||||
g_target_temp = Gauge(
|
|
||||||
"zont_target_temp_celsius",
|
|
||||||
"Целевая температура термостата, °C",
|
|
||||||
)
|
|
||||||
g_pza_temp = Gauge(
|
|
||||||
"zont_pza_temp_celsius",
|
|
||||||
"Расчётная температура ПЗА, °C",
|
|
||||||
)
|
|
||||||
g_boiler_work = Gauge(
|
|
||||||
"zont_boiler_work_seconds",
|
|
||||||
"Время работы котла за последнюю минуту, сек",
|
|
||||||
)
|
|
||||||
g_power = Gauge(
|
|
||||||
"zont_power_on",
|
|
||||||
"Наличие основного питания (1=есть, 0=нет)",
|
|
||||||
)
|
|
||||||
g_boiler_fail = Gauge(
|
|
||||||
"zont_boiler_fail",
|
|
||||||
"Авария котла (1=есть, 0=нет)",
|
|
||||||
)
|
|
||||||
g_gate = Gauge(
|
|
||||||
"zont_gate_on",
|
|
||||||
"Режим комнатного термостата активен (1/0)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Температурные датчики (H-серия) ---
|
|
||||||
g_thermo_temp = Gauge(
|
|
||||||
"zont_thermometer_temperature_celsius",
|
|
||||||
"Показание термодатчика, °C",
|
|
||||||
["uuid", "name", "type"],
|
|
||||||
)
|
|
||||||
g_thermo_state = Gauge(
|
|
||||||
"zont_thermometer_state",
|
|
||||||
"Состояние термодатчика (1=ok, 0=malfunction)",
|
|
||||||
["uuid", "name", "type"],
|
|
||||||
)
|
|
||||||
g_thermo_last_update = Gauge(
|
|
||||||
"zont_thermometer_last_update_timestamp",
|
|
||||||
"Unix-время последнего обновления показаний датчика",
|
|
||||||
["uuid", "name"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- OpenTherm ---
|
|
||||||
g_ot_flag = Gauge(
|
|
||||||
"zont_opentherm_flag",
|
|
||||||
"Флаги состояния OpenTherm (1=активен)",
|
|
||||||
["flag"],
|
|
||||||
)
|
|
||||||
g_ot_cs = Gauge(
|
|
||||||
"zont_opentherm_ch_setpoint_celsius",
|
|
||||||
"Расчётная температура теплоносителя отопления (OpenTherm), °C",
|
|
||||||
)
|
|
||||||
g_ot_bt = Gauge(
|
|
||||||
"zont_opentherm_boiler_temp_celsius",
|
|
||||||
"Фактическая температура теплоносителя (OpenTherm), °C",
|
|
||||||
)
|
|
||||||
g_ot_dt = Gauge(
|
|
||||||
"zont_opentherm_dhw_temp_celsius",
|
|
||||||
"Фактическая температура ГВС (OpenTherm), °C",
|
|
||||||
)
|
|
||||||
g_ot_rwt = Gauge(
|
|
||||||
"zont_opentherm_return_temp_celsius",
|
|
||||||
"Температура обратного потока (OpenTherm), °C",
|
|
||||||
)
|
|
||||||
g_ot_outside = Gauge(
|
|
||||||
"zont_opentherm_outside_temp_celsius",
|
|
||||||
"Уличная температура (OpenTherm), °C",
|
|
||||||
)
|
|
||||||
g_ot_rml = Gauge(
|
|
||||||
"zont_opentherm_flame_modulation_percent",
|
|
||||||
"Уровень модуляции горелки (OpenTherm), %",
|
|
||||||
)
|
|
||||||
g_ot_wp = Gauge(
|
|
||||||
"zont_opentherm_water_pressure_bar",
|
|
||||||
"Давление теплоносителя (OpenTherm), бар",
|
|
||||||
)
|
|
||||||
g_ot_flow = Gauge(
|
|
||||||
"zont_opentherm_dhw_flow_lpm",
|
|
||||||
"Скорость потока ГВС (OpenTherm), л/мин",
|
|
||||||
)
|
|
||||||
g_ot_fail_code = Gauge(
|
|
||||||
"zont_opentherm_fail_code",
|
|
||||||
"OEM-код аварии котла (OpenTherm)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Связь / контроллер ---
|
|
||||||
g_gsm_level = Gauge(
|
|
||||||
"zont_gsm_level",
|
|
||||||
"Уровень сигнала GSM [0..31]",
|
|
||||||
)
|
|
||||||
g_gsm_state = Gauge(
|
|
||||||
"zont_gsm_state",
|
|
||||||
"Состояние регистрации в GSM-сети (см. документацию)",
|
|
||||||
)
|
|
||||||
g_gsm_balance = Gauge(
|
|
||||||
"zont_gsm_balance",
|
|
||||||
"Баланс SIM-карты (в копейках/центах)",
|
|
||||||
)
|
|
||||||
g_voltage = Gauge(
|
|
||||||
"zont_supply_voltage_volts",
|
|
||||||
"Напряжение основного питания, В",
|
|
||||||
)
|
|
||||||
g_wifi_rssi = Gauge(
|
|
||||||
"zont_wifi_rssi",
|
|
||||||
"Уровень сигнала Wi-Fi (RSSI)",
|
|
||||||
)
|
|
||||||
g_status_flags = Gauge(
|
|
||||||
"zont_status_flags",
|
|
||||||
"Битовая маска состояния контроллера (см. документацию)",
|
|
||||||
)
|
|
||||||
g_internal_temp = Gauge(
|
|
||||||
"zont_internal_temperature_celsius",
|
|
||||||
"Показания встроенных температурных входов контроллера, °C",
|
|
||||||
["channel"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Охрана / авто (ZTC) ---
|
|
||||||
g_guard_state = Gauge(
|
|
||||||
"zont_guard_enabled",
|
|
||||||
"Охрана активна (1=enabled, 0=disabled)",
|
|
||||||
)
|
|
||||||
g_siren = Gauge(
|
|
||||||
"zont_siren_on",
|
|
||||||
"Сирена активна (1/0)",
|
|
||||||
)
|
|
||||||
g_engine_block = Gauge(
|
|
||||||
"zont_engine_block_on",
|
|
||||||
"Блокировка двигателя активна (1/0)",
|
|
||||||
)
|
|
||||||
g_webasto = Gauge(
|
|
||||||
"zont_webasto_on",
|
|
||||||
"Предпусковой подогреватель включён (1/0)",
|
|
||||||
)
|
|
||||||
g_auto_ignition = Gauge(
|
|
||||||
"zont_auto_ignition_state",
|
|
||||||
"Состояние автозапуска (0=disabled,1=enabling,2=enabled,3=webasto,4=webasto-confirmed)",
|
|
||||||
)
|
|
||||||
g_ignition = Gauge(
|
|
||||||
"zont_ignition_on",
|
|
||||||
"Зажигание включено (1/0)",
|
|
||||||
)
|
|
||||||
g_engine = Gauge(
|
|
||||||
"zont_engine_on",
|
|
||||||
"Двигатель запущен (1/0)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- Служебные ---
|
|
||||||
g_scrape_success = Gauge(
|
|
||||||
"zont_scrape_success",
|
|
||||||
"1 если последний опрос прошёл успешно, 0 иначе",
|
|
||||||
)
|
|
||||||
g_scrape_timestamp = Gauge(
|
|
||||||
"zont_scrape_timestamp_seconds",
|
|
||||||
"Unix-время последнего успешного опроса",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Хелперы
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _b(v: Any) -> Optional[int]:
|
|
||||||
"""Bool → 0/1, None → None."""
|
|
||||||
if v is None:
|
|
||||||
return None
|
return None
|
||||||
return 1 if v else 0
|
# Последний элемент массива имеет актуальное значение
|
||||||
|
last_entry = dta_list[-1]
|
||||||
|
if isinstance(last_entry, list) and len(last_entry) >= 2:
|
||||||
def _guard_state_to_int(v: Optional[str]) -> Optional[int]:
|
return last_entry[1]
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
if v == "enabled":
|
|
||||||
return 1
|
|
||||||
if v in ("disabled", "enabling"):
|
|
||||||
return 0
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def collect_metrics():
|
||||||
|
"""Основная функция сбора метрик."""
|
||||||
|
if not auth_token:
|
||||||
|
logging.warning("No auth token available, trying to authenticate...")
|
||||||
|
get_auth_token()
|
||||||
|
if not auth_token:
|
||||||
|
return
|
||||||
|
|
||||||
def _auto_ign_state_to_int(v: Optional[str]) -> Optional[int]:
|
# Запрос данных thermostat_work за последний час (3600 секунд)
|
||||||
"""
|
|
||||||
Кодируем строку автозапуска в число:
|
|
||||||
0 — disabled
|
|
||||||
1 — enabling
|
|
||||||
2 — enabled
|
|
||||||
3 — webasto
|
|
||||||
4 — webasto-confirmed
|
|
||||||
"""
|
|
||||||
mapping = {
|
|
||||||
"disabled": 0,
|
|
||||||
"enabling": 1,
|
|
||||||
"enabled": 2,
|
|
||||||
"webasto": 3,
|
|
||||||
"webasto-confirmed": 4,
|
|
||||||
}
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
return mapping.get(v)
|
|
||||||
|
|
||||||
|
|
||||||
def _set(gauge: Gauge, value: Any, **labels: str) -> None:
|
|
||||||
"""Безопасная установка метрики."""
|
|
||||||
if value is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if labels:
|
|
||||||
gauge.labels(**labels).set(float(value))
|
|
||||||
else:
|
|
||||||
gauge.set(float(value))
|
|
||||||
except Exception as e:
|
|
||||||
log.debug("Не удалось установить метрику: %s", e)
|
|
||||||
|
|
||||||
|
|
||||||
def _dta_last_value(dta: list) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Возвращает последнее значение из Delta-time Array.
|
|
||||||
Формат: [[t1, v1], [-dt, v2], ...]
|
|
||||||
"""
|
|
||||||
if not dta:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return dta[-1][1]
|
|
||||||
except (IndexError, TypeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Обновление метрик из /devices (state = поле io)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def update_from_devices(device: dict) -> None:
|
|
||||||
io = device.get("io") or {}
|
|
||||||
|
|
||||||
# --- Общая информация ---
|
|
||||||
g_device_info.info({
|
|
||||||
"id": str(device.get("id", "")),
|
|
||||||
"serial": str(device.get("serial", "")),
|
|
||||||
"name": str(device.get("name", "")),
|
|
||||||
"devtype": str(device.get("devtype", device.get("type", ""))),
|
|
||||||
})
|
|
||||||
|
|
||||||
# --- Термостат (last-boiler-state) ---
|
|
||||||
lbs = io.get("last-boiler-state")
|
|
||||||
if isinstance(lbs, dict):
|
|
||||||
_set(g_target_temp, lbs.get("target_temp"))
|
|
||||||
_set(g_pza_temp, lbs.get("pza_t"))
|
|
||||||
_set(g_boiler_work, lbs.get("boiler_work_time"))
|
|
||||||
_set(g_power, _b(lbs.get("power")))
|
|
||||||
_set(g_boiler_fail, _b(lbs.get("fail")))
|
|
||||||
_set(g_gate, _b(lbs.get("gate")))
|
|
||||||
|
|
||||||
ot = lbs.get("ot")
|
|
||||||
if isinstance(ot, dict):
|
|
||||||
# Флаги OpenTherm — сбрасываем все известные и ставим активные
|
|
||||||
known_flags = ["f", "ch", "dhw", "fl", "cl", "ch2", "di"]
|
|
||||||
active = set(ot.get("s") or [])
|
|
||||||
for flag in known_flags:
|
|
||||||
_set(g_ot_flag, 1 if flag in active else 0, flag=flag)
|
|
||||||
_set(g_ot_cs, ot.get("cs"))
|
|
||||||
_set(g_ot_bt, ot.get("bt"))
|
|
||||||
_set(g_ot_dt, ot.get("dt"))
|
|
||||||
_set(g_ot_rwt, ot.get("rwt"))
|
|
||||||
_set(g_ot_outside, ot.get("ot"))
|
|
||||||
_set(g_ot_rml, ot.get("rml"))
|
|
||||||
_set(g_ot_wp, ot.get("wp"))
|
|
||||||
_set(g_ot_flow, ot.get("fr"))
|
|
||||||
|
|
||||||
ff = ot.get("ff")
|
|
||||||
if isinstance(ff, dict):
|
|
||||||
_set(g_ot_fail_code, ff.get("c"))
|
|
||||||
|
|
||||||
# --- Температурные датчики (в настройке thermometers) ---
|
|
||||||
thermometers = device.get("thermometers") or []
|
|
||||||
# Удаляем старые серии, чтобы не тянуть мёртвые датчики
|
|
||||||
for metric in (g_thermo_temp, g_thermo_state, g_thermo_last_update):
|
|
||||||
try:
|
|
||||||
metric.clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
for t in thermometers:
|
|
||||||
if not t.get("is_assigned_to_slot", True):
|
|
||||||
continue
|
|
||||||
uuid = str(t.get("uuid", ""))
|
|
||||||
name = str(t.get("name", ""))
|
|
||||||
ttype = str(t.get("type", ""))
|
|
||||||
last_state = t.get("last_state")
|
|
||||||
state_val = 1 if last_state == "ok" else 0
|
|
||||||
_set(g_thermo_temp, t.get("last_value"), uuid=uuid, name=name, type=ttype)
|
|
||||||
_set(g_thermo_state, state_val, uuid=uuid, name=name, type=ttype)
|
|
||||||
_set(g_thermo_last_update, t.get("last_value_time"), uuid=uuid, name=name)
|
|
||||||
|
|
||||||
# --- Связь / контроллер ---
|
|
||||||
_set(g_gsm_level, io.get("gsm-level"))
|
|
||||||
gsm_state = io.get("gsm-state")
|
|
||||||
if isinstance(gsm_state, dict):
|
|
||||||
_set(g_gsm_level, gsm_state.get("level"))
|
|
||||||
# state приходит строкой — приводим к int по справочнику
|
|
||||||
st_map = {
|
|
||||||
"not-registered": 0,
|
|
||||||
"home-network": 1,
|
|
||||||
"searching": 2,
|
|
||||||
"rejected": 3,
|
|
||||||
"roaming": 5,
|
|
||||||
"unknown": 255,
|
|
||||||
}
|
|
||||||
st = gsm_state.get("state")
|
|
||||||
if st in st_map:
|
|
||||||
_set(g_gsm_state, st_map[st])
|
|
||||||
_set(g_voltage, io.get("voltage"))
|
|
||||||
_set(g_wifi_rssi, io.get("wifi-rssi"))
|
|
||||||
_set(g_status_flags, io.get("status-flags"))
|
|
||||||
|
|
||||||
# --- Охрана ---
|
|
||||||
_set(g_guard_state, _guard_state_to_int(io.get("guard-state")))
|
|
||||||
_set(g_siren, _b(io.get("siren")))
|
|
||||||
|
|
||||||
# --- Автомобиль (ZTC) ---
|
|
||||||
_set(g_engine_block, _b(io.get("engine-block")))
|
|
||||||
_set(g_webasto, _b(io.get("webasto")))
|
|
||||||
_set(g_ignition, _b(io.get("ignition-state")))
|
|
||||||
_set(g_engine, _b(io.get("engine-state")))
|
|
||||||
|
|
||||||
auto = io.get("auto-ignition")
|
|
||||||
if isinstance(auto, dict):
|
|
||||||
_set(g_auto_ignition, _auto_ign_state_to_int(auto.get("state")))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Обновление метрик из /load_data (история: ztc_state, temperature, ...)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def update_from_load_data(client: ZontClient, device_id: int) -> None:
|
|
||||||
"""
|
|
||||||
Догружаем некоторые исторические метрики,
|
|
||||||
которых нет в /devices: ztc_state, temperature (для H-серии).
|
|
||||||
Берём окно за последние 10 минут.
|
|
||||||
"""
|
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
mintime = now - 600
|
mintime = now - 3600
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"requests": [{
|
||||||
|
"device_id": DEVICE_ID,
|
||||||
|
"data_types": ["thermostat_work"],
|
||||||
|
"mintime": mintime,
|
||||||
|
"maxtime": now
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
# ztc_state — есть не у всех устройств, ошибку игнорируем
|
|
||||||
try:
|
try:
|
||||||
resp = client.load_data(
|
resp = requests.post(
|
||||||
device_id=device_id,
|
f"{API_BASE}/load_data",
|
||||||
data_types=["ztc_state"],
|
headers={
|
||||||
mintime=mintime,
|
'X-ZONT-Client': ZONT_CLIENT,
|
||||||
maxtime=now,
|
'X-ZONT-Token': auth_token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
timeout=15
|
||||||
)
|
)
|
||||||
st = resp.get("ztc_state")
|
resp.raise_for_status()
|
||||||
if isinstance(st, dict):
|
data = resp.json()
|
||||||
_set(g_gsm_level, _dta_last_value(st.get("gsm_level") or []))
|
|
||||||
_set(g_gsm_state, _dta_last_value(st.get("gsm_state") or []))
|
if not data.get('ok'):
|
||||||
_set(g_gsm_balance, _dta_last_value(st.get("gsm_balance") or []))
|
logging.error(f"API returned error: {data.get('error_ui', data.get('error'))}")
|
||||||
_set(g_voltage, _dta_last_value(st.get("voltage") or []))
|
# Если токен протух — сбрасываем его
|
||||||
_set(g_wifi_rssi, _dta_last_value(st.get("wifi_rssi") or []))
|
if data.get('error') in ['unauthorized', 'token_expired']:
|
||||||
_set(g_status_flags, _dta_last_value(st.get("status_flags") or []))
|
global auth_token
|
||||||
|
auth_token = None
|
||||||
|
return
|
||||||
|
|
||||||
|
responses = data.get('responses', [])
|
||||||
|
if not responses:
|
||||||
|
logging.warning("Empty responses from API.")
|
||||||
|
return
|
||||||
|
|
||||||
|
work_data = responses[0].get('thermostat_work')
|
||||||
|
if not work_data:
|
||||||
|
logging.info("No thermostat_work data in response.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. Время работы котла за последнюю минуту
|
||||||
|
boiler_work = fetch_last_value(work_data.get('boiler_work_time'))
|
||||||
|
if boiler_work is not None:
|
||||||
|
BOILER_WORK.labels(device_id=str(DEVICE_ID)).set(boiler_work)
|
||||||
|
|
||||||
|
# 2. Целевая температура
|
||||||
|
target_temp = fetch_last_value(work_data.get('target_temp'))
|
||||||
|
if target_temp is not None:
|
||||||
|
TARGET_TEMP.labels(device_id=str(DEVICE_ID)).set(target_temp)
|
||||||
|
|
||||||
|
# 3. Температура с датчиков (если есть в ответе)
|
||||||
|
# В thermostat_work датчики могут быть в отдельном поле или не быть вовсе.
|
||||||
|
# Здесь пример для поля temperature, если оно придёт.
|
||||||
|
temps = work_data.get('temperature')
|
||||||
|
if temps and isinstance(temps, dict):
|
||||||
|
for sensor_id, sensor_info in temps.items():
|
||||||
|
if not isinstance(sensor_info, dict): continue
|
||||||
|
name = sensor_info.get('name', sensor_id)
|
||||||
|
val = fetch_last_value(sensor_info.get('temperature'))
|
||||||
|
if val is not None:
|
||||||
|
TEMP_GAUGE.labels(
|
||||||
|
device_id=str(DEVICE_ID),
|
||||||
|
sensor_name=name,
|
||||||
|
sensor_type='thermostat_sensor'
|
||||||
|
).set(val)
|
||||||
|
|
||||||
|
logging.info(f"Metrics collected successfully for device {DEVICE_ID}")
|
||||||
|
|
||||||
temps = st.get("temperature") or []
|
|
||||||
if isinstance(temps, list):
|
|
||||||
for idx, series in enumerate(temps):
|
|
||||||
_set(
|
|
||||||
g_internal_temp,
|
|
||||||
_dta_last_value(series or []),
|
|
||||||
channel=str(idx),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.debug("ztc_state недоступен: %s", e)
|
logging.error(f"Error during metric collection: {e}")
|
||||||
|
|
||||||
# temperature — есть у H-серии
|
if __name__ == '__main__':
|
||||||
try:
|
# Проверка обязательных переменных
|
||||||
resp = client.load_data(
|
if not all([ZONT_LOGIN, ZONT_PASSWORD, DEVICE_ID]):
|
||||||
device_id=device_id,
|
logging.critical("Missing required environment variables: ZONT_LOGIN, ZONT_PASSWORD, ZONT_DEVICE_ID")
|
||||||
data_types=["temperature"],
|
exit(1)
|
||||||
mintime=mintime,
|
|
||||||
maxtime=now,
|
|
||||||
)
|
|
||||||
temp = resp.get("temperature")
|
|
||||||
if isinstance(temp, dict):
|
|
||||||
# НЕ очищаем здесь — иначе затрём данные из /devices.
|
|
||||||
# Просто дописываем исторические серии с суффиксом type=history.
|
|
||||||
for uuid, item in temp.items():
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
_set(
|
|
||||||
g_thermo_temp,
|
|
||||||
_dta_last_value(item.get("temperature") or []),
|
|
||||||
uuid=str(uuid),
|
|
||||||
name=str(item.get("name", "")),
|
|
||||||
type="history",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.debug("temperature недоступен: %s", e)
|
|
||||||
|
|
||||||
|
# Первичная аутентификация
|
||||||
|
get_auth_token()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Запуск HTTP-сервера Prometheus на порту 9101
|
||||||
# Основной цикл
|
start_http_server(9101)
|
||||||
# ---------------------------------------------------------------------------
|
logging.info("Prometheus exporter started on port 9101")
|
||||||
|
|
||||||
def pick_device(client: ZontClient, preferred_id: int) -> Optional[dict]:
|
# Основной цикл
|
||||||
"""Возвращает объект устройства (первое подходящее или с заданным ID)."""
|
while True:
|
||||||
devices = client.devices(load_io=True)
|
collect_metrics()
|
||||||
if not devices:
|
time.sleep(SCRAPE_INTERVAL)
|
||||||
log.warning("У пользователя нет устройств")
|
|
||||||
return None
|
|
||||||
if preferred_id:
|
|
||||||
for d in devices:
|
|
||||||
if d.get("id") == preferred_id:
|
|
||||||
return d
|
|
||||||
log.warning("Устройство id=%s не найдено, беру первое", preferred_id)
|
|
||||||
return devices[0]
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_once(client: ZontClient) -> None:
|
|
||||||
device = pick_device(client, ZONT_DEVICE_ID)
|
|
||||||
if device is None:
|
|
||||||
g_scrape_success.set(0)
|
|
||||||
return
|
|
||||||
|
|
||||||
update_from_devices(device)
|
|
||||||
update_from_load_data(client, device["id"])
|
|
||||||
|
|
||||||
g_scrape_success.set(1)
|
|
||||||
g_scrape_timestamp.set(time.time())
|
|
||||||
log.info(
|
|
||||||
"Метрики обновлены (device_id=%s, name=%s)",
|
|
||||||
device.get("id"),
|
|
||||||
device.get("name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_sigterm(signum, frame): # noqa: ARG001
|
|
||||||
log.info("Получен сигнал %s, завершаюсь...", signum)
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
# Корректная остановка в контейнере (docker stop → SIGTERM)
|
|
||||||
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
||||||
signal.signal(signal.SIGINT, _handle_sigterm)
|
|
||||||
|
|
||||||
log.info("Запуск ZONT exporter на порту %d", EXPORTER_PORT)
|
|
||||||
log.info(
|
|
||||||
"Логин: %s, устройство: %s, интервал: %ds",
|
|
||||||
ZONT_LOGIN,
|
|
||||||
ZONT_DEVICE_ID or "auto",
|
|
||||||
ZONT_POLL_INTERVAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = ZontClient(ZONT_LOGIN, ZONT_PASSWORD, ZONT_EMAIL, token=ZONT_TOKEN)
|
|
||||||
|
|
||||||
# Первый опрос синхронно, чтобы /metrics сразу отдавал данные
|
|
||||||
try:
|
|
||||||
scrape_once(client)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Первый опрос не удался: %s", e)
|
|
||||||
g_scrape_success.set(0)
|
|
||||||
|
|
||||||
start_http_server(EXPORTER_PORT)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
time.sleep(ZONT_POLL_INTERVAL)
|
|
||||||
try:
|
|
||||||
scrape_once(client)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception("Ошибка опроса: %s", e)
|
|
||||||
g_scrape_success.set(0)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
log.info("Остановка по сигналу")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user