Update
This commit is contained in:
+21
-17
@@ -1,29 +1,33 @@
|
||||
FROM python:3.12-slim
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
LABEL maintainer="zont-exporter"
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# Отключаем создание .pyc файлов и включаем небуферизованный вывод
|
||||
# Меньше мусора в логах python
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=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
|
||||
|
||||
# Устанавливаем зависимости
|
||||
COPY requirements.txt .
|
||||
# Ставим зависимости отдельным слоем (кэшируется)
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копируем исходный код
|
||||
COPY zont_exporter.py .
|
||||
# Копируем код
|
||||
COPY --chown=app:app zont_exporter.py ./
|
||||
|
||||
# Создаём непривилегированного пользователя
|
||||
RUN useradd -m -u 1000 exporter && chown -R exporter:exporter /app
|
||||
USER exporter
|
||||
USER app
|
||||
|
||||
# Открываем порт экспортера
|
||||
EXPOSE 8000
|
||||
EXPOSE 9877
|
||||
|
||||
# Healthcheck — проверяем, что /metrics отвечает
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/metrics').read()" || exit 1
|
||||
# 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)"
|
||||
|
||||
CMD ["python", "zont_exporter.py"]
|
||||
ENTRYPOINT ["python", "zont_exporter.py"]
|
||||
@@ -1,2 +1,2 @@
|
||||
requests>=2.31.0
|
||||
prometheus-client>=0.19.0
|
||||
requests==2.32.3
|
||||
prometheus-client==0.21.1
|
||||
+600
-184
@@ -1,208 +1,624 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ZONT API Prometheus Exporter
|
||||
|
||||
Экспортирует метрики отопительного контроллера ZONT (H-1, H-2, H-1000, H-2000,
|
||||
Mega SX, ZTC и др.) в формате Prometheus.
|
||||
|
||||
Документация API: https://my.zont.online/api/
|
||||
|
||||
Переменные окружения:
|
||||
ZONT_LOGIN — логин от my.zont.online (обязательно)
|
||||
ZONT_PASSWORD — пароль (обязательно, если не задан ZONT_TOKEN)
|
||||
ZONT_DEVICE_ID — ID устройства (0 = взять первое; по умолчанию 0)
|
||||
ZONT_EMAIL — значение заголовка X-ZONT-Client (по умолчанию exporter@example.com)
|
||||
ZONT_TOKEN — готовый auth-токен (опционально)
|
||||
ZONT_POLL_INTERVAL — интервал опроса, сек (по умолчанию 60)
|
||||
EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9877)
|
||||
LOG_LEVEL — DEBUG/INFO/WARNING/ERROR (по умолчанию INFO)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
from prometheus_client import start_http_server, Gauge
|
||||
from prometheus_client.core import GaugeMetricFamily, REGISTRY
|
||||
from prometheus_client import start_http_server, Gauge, Info
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Настройки (через переменные окружения)
|
||||
# ---------------------------------------------------------------------------
|
||||
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")
|
||||
|
||||
API_BASE = "https://my.zont.online/api"
|
||||
|
||||
# --- Настройка логирования ---
|
||||
logging.basicConfig(
|
||||
level=os.getenv('LOG_LEVEL', 'INFO').upper(),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger('zont-exporter')
|
||||
|
||||
# --- Конфигурация из переменных окружения ---
|
||||
ZONT_LOGIN = os.getenv('ZONT_LOGIN')
|
||||
ZONT_PASSWORD = os.getenv('ZONT_PASSWORD')
|
||||
ZONT_CLIENT_EMAIL = os.getenv('ZONT_CLIENT_EMAIL', 'exporter@localhost')
|
||||
API_URL = os.getenv('ZONT_API_URL', 'https://my.zont.online/api')
|
||||
SCRAPE_INTERVAL = int(os.getenv('SCRAPE_INTERVAL', '60'))
|
||||
EXPORTER_PORT = int(os.getenv('EXPORTER_PORT', '8000'))
|
||||
|
||||
# Проверка обязательных параметров
|
||||
if not ZONT_LOGIN or not ZONT_PASSWORD:
|
||||
logger.error("Не заданы ZONT_LOGIN и/или ZONT_PASSWORD")
|
||||
raise SystemExit(1)
|
||||
log = logging.getLogger("zont-exporter")
|
||||
|
||||
|
||||
class ZontCollector(object):
|
||||
"""Кастомный коллектор, который собирает метрики с API ZONT."""
|
||||
# ---------------------------------------------------------------------------
|
||||
# Клиент ZONT API
|
||||
# ---------------------------------------------------------------------------
|
||||
class ZontClient:
|
||||
"""Тонкая обёртка над REST API ZONT с автоматическим переполучением токена."""
|
||||
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
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_auth_token(self):
|
||||
"""Получает или обновляет токен авторизации."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f'{API_URL}/get_authtoken',
|
||||
auth=(ZONT_LOGIN, ZONT_PASSWORD),
|
||||
headers={'X-ZONT-Client': ZONT_CLIENT_EMAIL},
|
||||
json={"client_name": "Prometheus-Exporter"},
|
||||
timeout=15
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get('ok'):
|
||||
self.token = data['token']
|
||||
logger.info("Токен успешно получен.")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Ошибка получения токена: {data.get('error_ui')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Сетевая ошибка при получении токена: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Неожиданная ошибка при получении токена: {e}")
|
||||
return False
|
||||
# -- Аутентификация ----------------------------------------------------
|
||||
def get_authtoken(self, client_name: str = "prometheus-exporter") -> str:
|
||||
"""POST /get_authtoken — получаем токен по логину/паролю."""
|
||||
r = self.session.post(
|
||||
f"{API_BASE}/get_authtoken",
|
||||
json={"client_name": client_name},
|
||||
timeout=20,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not data.get("ok"):
|
||||
raise RuntimeError(f"get_authtoken failed: {data}")
|
||||
self.token = data["token"]
|
||||
log.info("Получен новый auth-токен")
|
||||
return self.token
|
||||
|
||||
def _fetch_data(self):
|
||||
"""Запрашивает данные по всем устройствам."""
|
||||
def _headers(self) -> dict:
|
||||
h = {}
|
||||
if self.token:
|
||||
h["X-ZONT-Token"] = self.token
|
||||
return h
|
||||
|
||||
# -- Универсальный POST -------------------------------------------------
|
||||
def _post(self, method: str, payload: dict) -> dict:
|
||||
"""POST с автоматическим переполучением токена при 403."""
|
||||
if not self.token:
|
||||
if not self._get_auth_token():
|
||||
return None
|
||||
self.get_authtoken()
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f'{API_URL}/devices',
|
||||
headers={
|
||||
'X-ZONT-Client': ZONT_CLIENT_EMAIL,
|
||||
'X-ZONT-Token': self.token
|
||||
},
|
||||
json={'load_io': True},
|
||||
timeout=15
|
||||
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}: не удалось выполнить запрос")
|
||||
|
||||
if response.status_code == 403:
|
||||
logger.warning("Токен недействителен, обновляем...")
|
||||
if self._get_auth_token():
|
||||
return self._fetch_data()
|
||||
return None
|
||||
# -- Высокоуровневые методы --------------------------------------------
|
||||
def devices(self, load_io: bool = True) -> list:
|
||||
"""POST /devices — список устройств с состояниями."""
|
||||
return self._post("devices", {"load_io": load_io}).get("devices", [])
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get('ok'):
|
||||
return data.get('devices', [])
|
||||
else:
|
||||
logger.error(f"Ошибка в ответе devices: {data.get('error_ui')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Сетевая ошибка при запросе devices: {e}")
|
||||
except Exception as e:
|
||||
logger.exception(f"Неожиданная ошибка при запросе devices: {e}")
|
||||
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 1 if v else 0
|
||||
|
||||
|
||||
def _guard_state_to_int(v: Optional[str]) -> Optional[int]:
|
||||
if v is None:
|
||||
return None
|
||||
if v == "enabled":
|
||||
return 1
|
||||
if v in ("disabled", "enabling"):
|
||||
return 0
|
||||
return None
|
||||
|
||||
|
||||
def _auto_ign_state_to_int(v: Optional[str]) -> Optional[int]:
|
||||
"""
|
||||
Кодируем строку автозапуска в число:
|
||||
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
|
||||
|
||||
def collect(self):
|
||||
devices = self._fetch_data()
|
||||
if not devices:
|
||||
return
|
||||
|
||||
# --- Температуры ---
|
||||
temp_gauge = GaugeMetricFamily(
|
||||
'zont_temperature_celsius',
|
||||
'Температура с датчиков ZONT',
|
||||
labels=['device_id', 'device_name', 'sensor_name']
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Обновление метрик из /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())
|
||||
mintime = now - 600
|
||||
|
||||
# ztc_state — есть не у всех устройств, ошибку игнорируем
|
||||
try:
|
||||
resp = client.load_data(
|
||||
device_id=device_id,
|
||||
data_types=["ztc_state"],
|
||||
mintime=mintime,
|
||||
maxtime=now,
|
||||
)
|
||||
|
||||
# --- Работа котла ---
|
||||
boiler_work_gauge = GaugeMetricFamily(
|
||||
'zont_boiler_work_seconds',
|
||||
'Время работы котла за последнюю минуту (секунд)',
|
||||
labels=['device_id', 'device_name']
|
||||
)
|
||||
|
||||
# --- Режим термостата ---
|
||||
mode_gauge = GaugeMetricFamily(
|
||||
'zont_thermostat_mode',
|
||||
'Режим термостата (0=выкл, 1=комфорт, 2=эконом, 3=расписание)',
|
||||
labels=['device_id', 'device_name']
|
||||
)
|
||||
|
||||
# --- Целевая температура ---
|
||||
target_gauge = GaugeMetricFamily(
|
||||
'zont_target_temperature_celsius',
|
||||
'Целевая температура',
|
||||
labels=['device_id', 'device_name']
|
||||
)
|
||||
|
||||
# --- Авария котла ---
|
||||
fail_gauge = GaugeMetricFamily(
|
||||
'zont_boiler_fail',
|
||||
'Авария котла (1 - есть, 0 - нет)',
|
||||
labels=['device_id', 'device_name']
|
||||
)
|
||||
|
||||
# --- Питание ---
|
||||
power_gauge = GaugeMetricFamily(
|
||||
'zont_power_ok',
|
||||
'Наличие основного питания (1/0)',
|
||||
labels=['device_id', 'device_name']
|
||||
)
|
||||
|
||||
# --- Сигнал GSM ---
|
||||
signal_gauge = GaugeMetricFamily(
|
||||
'zont_signal_level',
|
||||
'Уровень сигнала GSM',
|
||||
labels=['device_id', 'device_name', 'type']
|
||||
)
|
||||
|
||||
for device in devices:
|
||||
dev_id = str(device.get('id'))
|
||||
dev_name = device.get('name', 'unknown')
|
||||
io_data = device.get('io', {})
|
||||
if not io_data:
|
||||
continue
|
||||
|
||||
# --- Термодатчики ---
|
||||
for sensor in device.get('thermometers', []):
|
||||
if sensor.get('is_assigned_to_slot') and sensor.get('last_value') is not None:
|
||||
temp_gauge.add_metric(
|
||||
[dev_id, dev_name, sensor.get('name', 'sensor')],
|
||||
sensor['last_value']
|
||||
st = resp.get("ztc_state")
|
||||
if isinstance(st, dict):
|
||||
_set(g_gsm_level, _dta_last_value(st.get("gsm_level") or []))
|
||||
_set(g_gsm_state, _dta_last_value(st.get("gsm_state") or []))
|
||||
_set(g_gsm_balance, _dta_last_value(st.get("gsm_balance") or []))
|
||||
_set(g_voltage, _dta_last_value(st.get("voltage") or []))
|
||||
_set(g_wifi_rssi, _dta_last_value(st.get("wifi_rssi") or []))
|
||||
_set(g_status_flags, _dta_last_value(st.get("status_flags") or []))
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# --- Состояние котла ---
|
||||
boiler_state = io_data.get('last-boiler-state')
|
||||
if boiler_state:
|
||||
# Время работы котла
|
||||
work_time = boiler_state.get('boiler_work_time', 0)
|
||||
boiler_work_gauge.add_metric([dev_id, dev_name], work_time)
|
||||
|
||||
# Режим (строку в число)
|
||||
mode_map = {'idle': 0, 'comfort': 1, 'econom': 2, 'schedule': 3}
|
||||
mode_str = boiler_state.get('thermostat_mode', 'idle')
|
||||
mode_gauge.add_metric([dev_id, dev_name], mode_map.get(mode_str, 0))
|
||||
|
||||
# Целевая температура
|
||||
target = boiler_state.get('target_temp')
|
||||
if target is not None:
|
||||
target_gauge.add_metric([dev_id, dev_name], target)
|
||||
|
||||
# Авария
|
||||
fail = boiler_state.get('fail', False)
|
||||
fail_gauge.add_metric([dev_id, dev_name], 1 if fail else 0)
|
||||
|
||||
# Питание
|
||||
power = boiler_state.get('power', True)
|
||||
power_gauge.add_metric([dev_id, dev_name], 1 if power else 0)
|
||||
|
||||
# --- GSM ---
|
||||
gsm_state = io_data.get('gsm-state')
|
||||
if gsm_state and gsm_state.get('level') is not None:
|
||||
signal_gauge.add_metric([dev_id, dev_name, 'gsm'], gsm_state['level'])
|
||||
|
||||
yield temp_gauge
|
||||
yield boiler_work_gauge
|
||||
yield mode_gauge
|
||||
yield target_gauge
|
||||
yield fail_gauge
|
||||
yield power_gauge
|
||||
yield signal_gauge
|
||||
|
||||
except Exception as e:
|
||||
log.debug("ztc_state недоступен: %s", e)
|
||||
|
||||
# temperature — есть у H-серии
|
||||
try:
|
||||
resp = client.load_data(
|
||||
device_id=device_id,
|
||||
data_types=["temperature"],
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Основной цикл
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pick_device(client: ZontClient, preferred_id: int) -> Optional[dict]:
|
||||
"""Возвращает объект устройства (первое подходящее или с заданным ID)."""
|
||||
devices = client.devices(load_io=True)
|
||||
if not devices:
|
||||
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)
|
||||
|
||||
if __name__ == '__main__':
|
||||
REGISTRY.register(ZontCollector())
|
||||
start_http_server(EXPORTER_PORT)
|
||||
logger.info(f"Экспортер запущен на порту {EXPORTER_PORT}")
|
||||
|
||||
while True:
|
||||
time.sleep(10)
|
||||
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