From ef6356781db9a36f7f5475f35888af23bb0ebbed Mon Sep 17 00:00:00 2001 From: energys Date: Thu, 24 Sep 2026 14:48:25 +0900 Subject: [PATCH] Update --- zont-exporter/Dockerfile | 38 +- zont-exporter/requirements.txt | 4 +- zont-exporter/zont_exporter.py | 784 +++++++++++++++++++++++++-------- 3 files changed, 623 insertions(+), 203 deletions(-) diff --git a/zont-exporter/Dockerfile b/zont-exporter/Dockerfile index d4973eb..ff3b933 100644 --- a/zont-exporter/Dockerfile +++ b/zont-exporter/Dockerfile @@ -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"] \ No newline at end of file +ENTRYPOINT ["python", "zont_exporter.py"] \ No newline at end of file diff --git a/zont-exporter/requirements.txt b/zont-exporter/requirements.txt index da65529..544a31f 100644 --- a/zont-exporter/requirements.txt +++ b/zont-exporter/requirements.txt @@ -1,2 +1,2 @@ -requests>=2.31.0 -prometheus-client>=0.19.0 \ No newline at end of file +requests==2.32.3 +prometheus-client==0.21.1 \ No newline at end of file diff --git a/zont-exporter/zont_exporter.py b/zont-exporter/zont_exporter.py index 14a37a5..da74eab 100644 --- a/zont-exporter/zont_exporter.py +++ b/zont-exporter/zont_exporter.py @@ -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) \ No newline at end of file + 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() \ No newline at end of file