Update
Build and Push / maintence (push) Successful in 1s
Build and Push / buildApp (push) Successful in 17s
Build and Push / pushApp (push) Failing after 6s

This commit is contained in:
energys
2026-09-24 14:48:25 +09:00
parent b4291b93ce
commit ef6356781d
3 changed files with 623 additions and 203 deletions
+21 -17
View File
@@ -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 \ 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 WORKDIR /app
# Устанавливаем зависимости # Ставим зависимости отдельным слоем (кэшируется)
COPY requirements.txt . COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код # Копируем код
COPY zont_exporter.py . COPY --chown=app:app zont_exporter.py ./
# Создаём непривилегированного пользователя USER app
RUN useradd -m -u 1000 exporter && chown -R exporter:exporter /app
USER exporter
# Открываем порт экспортера EXPOSE 9877
EXPOSE 8000
# Healthcheck — проверяем, что /metrics отвечает # Healthcheck: сам /metrics должен отвечать 200
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/metrics').read()" || exit 1 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"]
+2 -2
View File
@@ -1,2 +1,2 @@
requests>=2.31.0 requests==2.32.3
prometheus-client>=0.19.0 prometheus-client==0.21.1
+596 -180
View File
@@ -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 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 from prometheus_client import start_http_server, Gauge, Info
from prometheus_client.core import GaugeMetricFamily, REGISTRY
# ---------------------------------------------------------------------------
# Настройки (через переменные окружения)
# ---------------------------------------------------------------------------
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( logging.basicConfig(
level=os.getenv('LOG_LEVEL', 'INFO').upper(), level=getattr(logging, LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
) )
logger = logging.getLogger('zont-exporter') log = 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)
class ZontCollector(object): # ---------------------------------------------------------------------------
"""Кастомный коллектор, который собирает метрики с API ZONT.""" # Клиент ZONT API
# ---------------------------------------------------------------------------
class ZontClient:
"""Тонкая обёртка над REST API ZONT с автоматическим переполучением токена."""
def __init__(self): def __init__(
self.token = None 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): # -- Аутентификация ----------------------------------------------------
"""Получает или обновляет токен авторизации.""" def get_authtoken(self, client_name: str = "prometheus-exporter") -> str:
try: """POST /get_authtoken — получаем токен по логину/паролю."""
response = requests.post( r = self.session.post(
f'{API_URL}/get_authtoken', f"{API_BASE}/get_authtoken",
auth=(ZONT_LOGIN, ZONT_PASSWORD), json={"client_name": client_name},
headers={'X-ZONT-Client': ZONT_CLIENT_EMAIL}, timeout=20,
json={"client_name": "Prometheus-Exporter"}, )
timeout=15 r.raise_for_status()
) data = r.json()
response.raise_for_status() if not data.get("ok"):
data = response.json() raise RuntimeError(f"get_authtoken failed: {data}")
if data.get('ok'): self.token = data["token"]
self.token = data['token'] log.info("Получен новый auth-токен")
logger.info("Токен успешно получен.") return self.token
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 _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.token:
if not self._get_auth_token(): self.get_authtoken()
return None
try: url = f"{API_BASE}/{method}"
response = requests.post( for attempt in (1, 2):
f'{API_URL}/devices', r = self.session.post(
headers={ url, json=payload, headers=self._headers(), timeout=20
'X-ZONT-Client': ZONT_CLIENT_EMAIL,
'X-ZONT-Token': self.token
},
json={'load_io': True},
timeout=15
) )
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("Токен недействителен, обновляем...") def devices(self, load_io: bool = True) -> list:
if self._get_auth_token(): """POST /devices — список устройств с состояниями."""
return self._fetch_data() return self._post("devices", {"load_io": load_io}).get("devices", [])
return None
response.raise_for_status() def load_data(
data = response.json() self,
if data.get('ok'): device_id: int,
return data.get('devices', []) data_types: list,
else: mintime: Optional[int] = None,
logger.error(f"Ошибка в ответе devices: {data.get('error_ui')}") maxtime: Optional[int] = None,
except requests.exceptions.RequestException as e: ) -> dict:
logger.error(f"Сетевая ошибка при запросе devices: {e}") """POST /load_data — история данных."""
except Exception as e: req: dict = {"device_id": device_id, "data_types": data_types}
logger.exception(f"Неожиданная ошибка при запросе devices: {e}") 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 return None
def collect(self):
devices = self._fetch_data()
if not devices:
return
# --- Температуры --- # ---------------------------------------------------------------------------
temp_gauge = GaugeMetricFamily( # Обновление метрик из /devices (state = поле io)
'zont_temperature_celsius', # ---------------------------------------------------------------------------
'Температура с датчиков ZONT',
labels=['device_id', 'device_name', 'sensor_name'] 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,
) )
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 []
boiler_work_gauge = GaugeMetricFamily( if isinstance(temps, list):
'zont_boiler_work_seconds', for idx, series in enumerate(temps):
'Время работы котла за последнюю минуту (секунд)', _set(
labels=['device_id', 'device_name'] g_internal_temp,
) _dta_last_value(series or []),
channel=str(idx),
# --- Режим термостата ---
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']
) )
except Exception as e:
log.debug("ztc_state недоступен: %s", e)
# --- Состояние котла --- # temperature — есть у H-серии
boiler_state = io_data.get('last-boiler-state') try:
if boiler_state: resp = client.load_data(
# Время работы котла device_id=device_id,
work_time = boiler_state.get('boiler_work_time', 0) data_types=["temperature"],
boiler_work_gauge.add_metric([dev_id, dev_name], work_time) mintime=mintime,
maxtime=now,
# Режим (строку в число) )
mode_map = {'idle': 0, 'comfort': 1, 'econom': 2, 'schedule': 3} temp = resp.get("temperature")
mode_str = boiler_state.get('thermostat_mode', 'idle') if isinstance(temp, dict):
mode_gauge.add_metric([dev_id, dev_name], mode_map.get(mode_str, 0)) # НЕ очищаем здесь — иначе затрём данные из /devices.
# Просто дописываем исторические серии с суффиксом type=history.
# Целевая температура for uuid, item in temp.items():
target = boiler_state.get('target_temp') if not isinstance(item, dict):
if target is not None: continue
target_gauge.add_metric([dev_id, dev_name], target) _set(
g_thermo_temp,
# Авария _dta_last_value(item.get("temperature") or []),
fail = boiler_state.get('fail', False) uuid=str(uuid),
fail_gauge.add_metric([dev_id, dev_name], 1 if fail else 0) name=str(item.get("name", "")),
type="history",
# Питание )
power = boiler_state.get('power', True) except Exception as e:
power_gauge.add_metric([dev_id, dev_name], 1 if power else 0) log.debug("temperature недоступен: %s", e)
# --- 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
if __name__ == '__main__': # ---------------------------------------------------------------------------
REGISTRY.register(ZontCollector()) # Основной цикл
# ---------------------------------------------------------------------------
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)
start_http_server(EXPORTER_PORT) start_http_server(EXPORTER_PORT)
logger.info(f"Экспортер запущен на порту {EXPORTER_PORT}")
while True: try:
time.sleep(10) 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()