This commit is contained in:
energys
2026-09-30 12:02:28 +09:00
parent bb7ca25cff
commit 1994b943c9
+350 -51
View File
@@ -1,46 +1,80 @@
"""
ZONT API Prometheus Exporter
ZONT API Prometheus Exporter + Loki event exporter
Экспортирует метрики отопительного контроллера ZONT (H-1, H-2, H-1000, H-2000,
Mega SX, ZTC и др.) в формате Prometheus.
Mega SX, ZTC и др.) в формате Prometheus и отправляет события устройства в Loki.
Документация API: https://my.zont.online/api/
Переменные окружения:
ZONT_LOGIN — логин от my.zont.online (обязательно)
ZONT_PASSWORD — пароль (обязательно, если не задан ZONT_TOKEN)
ZONT_DEVICE_ID — ID устройства
ZONT_EMAIL — значение заголовка X-ZONT-Client (по умолчанию exporter@example.com)
ZONT_TOKEN — готовый auth-токен (опционально)
ZONT_POLL_INTERVAL — интервал опроса, сек (по умолчанию 60)
ZONT_EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9101)
ZONT_LOG_LEVEL — DEBUG/INFO/WARNING/ERROR (по умолчанию INFO)
ZONT_DOOR_INVERT — инвертировать логику двери (1=да, 0=нет, по умолчанию 0)
ZONT_DOOR_SENSOR_TYPES — через запятую, sensor_type дискретных датчиков (по умолчанию 20)
ZONT_LOGIN — логин от my.zont.online (обязательно)
ZONT_PASSWORD — пароль (обязательно, если не задан ZONT_TOKEN)
ZONT_DEVICE_ID — ID устройства (обязательно)
ZONT_CLIENT — значение заголовка X-ZONT-Client (по умолчанию exporter@example.com)
ZONT_POLL_INTERVAL — интервал опроса, сек (по умолчанию 60)
ZONT_EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9101)
ZONT_LOG_LEVEL — DEBUG/INFO/WARNING/ERROR (по умолчанию INFO)
ZONT_DOOR_INVERT — инвертировать логику двери (true/false, по умолчанию false)
ZONT_DOOR_SENSOR_TYPES — через запятую, sensor_type дискретных датчиков (по умолчанию 20)
ZONT_LOKI_ENABLED — включить отправку событий в Loki (true/false, по умолчанию true)
ZONT_LOKI_URL — URL Loki push API (например http://loki:3100/loki/api/v1/push).
Если не задан — отправка событий отключена.
ZONT_LOKI_JOB — значение метки job для Loki (по умолчанию zont)
ZONT_EVENT_LOOKBACK — окно событий при первом опросе, сек (по умолчанию 2*POLL_INTERVAL)
ZONT_EVENT_FILTER — через запятую, типы событий (пусто = все)
ZONT_EVENT_MAX — максимум событий за один запрос (по умолчанию 500)
"""
import os
import time
import json
import logging
import requests
from prometheus_client import start_http_server, Gauge
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Утилиты ---
def env_bool(name, default=False):
"""Читает boolean из переменной окружения. Принимает true/false/1/0/yes/no/on/off."""
v = os.getenv(name)
if v is None:
return default
return v.strip().lower() in ('1', 'true', 'yes', 'on', 'y', 't')
# --- Конфигурация ---
logging.basicConfig(
level=os.getenv('ZONT_LOG_LEVEL', 'INFO').upper(),
format='%(asctime)s - %(levelname)s - %(message)s'
)
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'))
DEVICE_ID = int(os.getenv('ZONT_DEVICE_ID', '0'))
API_BASE = "https://my.zont.online/api"
POLL_INTERVAL = int(os.getenv('ZONT_POLL_INTERVAL', 60))
EXPORTER_PORT = int(os.getenv('ZONT_EXPORTER_PORT', 9101))
LOG_LEVEL = os.getenv('ZONT_LOG_LEVEL', 'INFO').upper()
# Логика двери
DOOR_INVERT = os.getenv('ZONT_DOOR_INVERT', '0') == '1'
DOOR_INVERT = env_bool('ZONT_DOOR_INVERT', False)
DOOR_SENSOR_TYPES = set(
int(x) for x in os.getenv('ZONT_DOOR_SENSOR_TYPES', '20').split(',') if x.strip()
)
# Loki
LOKI_URL = os.getenv('ZONT_LOKI_URL', '')
LOKI_ENABLED = env_bool('ZONT_LOKI_ENABLED', True) and bool(LOKI_URL)
LOKI_JOB = os.getenv('ZONT_LOKI_JOB', 'zont')
# События
EVENT_LOOKBACK = int(os.getenv('ZONT_EVENT_LOOKBACK', str(2 * POLL_INTERVAL)))
EVENT_FILTER = set(
x.strip() for x in os.getenv('ZONT_EVENT_FILTER', '').split(',') if x.strip()
)
EVENT_MAX = int(os.getenv('ZONT_EVENT_MAX', 500))
# --- Метрики Prometheus ---
# Общие
@@ -62,7 +96,7 @@ MEMORY_BYTES = Gauge('zont_memory_used_bytes', 'Memory usage bytes', ['device_id
# Погода
INTERNET_WEATHER = Gauge('zont_internet_weather_celsius', 'Internet weather outside temperature', ['device_id'])
# Температуры (объекты из z3k-state)
# Температуры
TEMP_CURR = Gauge('zont_temperature_celsius', 'Current temperature', ['device_id', 'object_id', 'name'])
SENSOR_OK = Gauge('zont_temperature_sensor_ok', 'Temperature sensor OK (1=ok)', ['device_id', 'object_id', 'name'])
@@ -81,31 +115,134 @@ BOILER_DS = Gauge('zont_boiler_ds_celsius', 'Boiler DHW setpoint', ['device_id',
BOILER_CONNECTED = Gauge('zont_boiler_connected', 'Boiler connected (1=yes,0=no)', ['device_id', 'adapter_id'])
BOILER_FAIL = Gauge('zont_boiler_fail', 'Boiler failure (1=fail)', ['device_id', 'adapter_id'])
# Дискретные датчики (дверь, окно и т.п.) на аналоговых входах
DOOR_OPEN = Gauge(
'zont_door_open',
'Door open (1=open, 0=closed)',
['device_id', 'input_id', 'name']
)
DOOR_VOLTAGE = Gauge(
'zont_door_voltage_volts',
'Voltage on door sensor analog input',
['device_id', 'input_id', 'name']
)
# Дискретные датчики
DOOR_OPEN = Gauge('zont_door_open', 'Door open (1=open, 0=closed)', ['device_id', 'input_id', 'name'])
DOOR_VOLTAGE = Gauge('zont_door_voltage_volts', 'Voltage on door sensor analog input', ['device_id', 'input_id', 'name'])
# Охранные зоны (помещения)
GUARD_ZONE_ONGUARD = Gauge(
'zont_guard_zone_onguard',
'Guard zone on guard (1=yes, 0=no)',
['device_id', 'zone_id', 'name']
)
# Охранные зоны
GUARD_ZONE_ONGUARD = Gauge('zont_guard_zone_onguard', 'Guard zone on guard (1=yes, 0=no)', ['device_id', 'zone_id', 'name'])
# Справочники ID -> имя для читаемых меток
# --- Справочники ---
_name_cache = {}
_analog_input_types = {} # {input_id: sensor_type}
_analog_input_types = {}
auth_token = None
_last_event_poll_time = 0
GSM_STATE_MAP = {
'not-registered': 0,
'home-network': 1,
'searching': 2,
'rejected': 3,
'roaming': 5,
}
# Человекочитаемые названия типов событий
EVENT_TYPE_RU = {
'Stop': 'Остановка',
'PowerOn': 'Включение устройства',
'PowerOff': 'Выключение устройства',
'MainPowerLost': 'Пропало основное питание',
'MainPowerFound': 'Появилось основное питание',
'GPSLost': 'Потеря GPS',
'GPSFound': 'Поиск GPS',
'GSMLost': 'Потеря GSM',
'GSMFound': 'Регистрация в GSM',
'connected': 'Соединение с сервером',
'disconnected': 'Отключение от сервера',
'reconnected': 'Переподключение к серверу',
'GuardOn': 'Постановка на охрану',
'GuardOff': 'Снятие с охраны',
'AlarmButton': 'Тревожная кнопка',
'Shock1': 'Слабый удар',
'Shock2': 'Сильный удар',
'Door': 'Открытие двери',
'Door1': 'Открытие двери 1',
'Door2': 'Открытие двери 2',
'Door3': 'Открытие двери 3',
'Door4': 'Открытие двери 4',
'Door5': 'Открытие двери 5',
'Door6': 'Открытие двери 6',
'Hood': 'Открытие капота',
'Trunk': 'Открытие багажника',
'HoodTrunk': 'Открытие капота/багажника',
'AutoIgnition': 'Автозапуск',
'EngineBreakdown': 'Аварийная остановка двигателя',
'EngineStarted': 'Двигатель заведён',
'EngineStopped': 'Двигатель остановлен',
'SoftwareUpgrade': 'Обновление ПО',
'Alarm': 'Тревога',
'AlarmIgnition': 'Тревога по зажиганию',
'AlarmTilt': 'Тревога по наклону',
'AlarmBrake': 'Тревога по тормозу',
'EngineBlock': 'Блокировка двигателя',
'Moving': 'Движение',
'Blackout': 'Глушение GSM',
'GSMLostAlarm': 'Тревога: пропал GSM',
'DriverCallButton': 'Кнопка вызова водителя',
'OutCallTone': 'Исходящий звонок: гудок',
'OutCallConnection': 'Исходящий звонок: соединение',
'OutCallEnd': 'Исходящий звонок: завершён',
'OutSMS': 'Исходящее SMS',
'InCall': 'Входящий звонок',
'InCallConnection': 'Входящий звонок: соединение',
'InCallEnd': 'Входящий звонок: завершён',
'InSMS': 'Входящее SMS',
'ThermometerUpdate': 'Найден термодатчик',
'TemperatureHigh': 'Температура выше порога',
'TemperatureLow': 'Температура ниже порога',
'ThermometerMalfunction': 'Термодатчик недоступен',
'BatteryLow': 'Разряд аккумулятора',
'RadioFOBAdded': 'Добавлен брелок/радиореле',
'BoilerFail': 'Авария котла',
'BoilerFailCancel': 'Авария котла устранена',
'Landmark': 'Метка',
'ZoneAlarm': 'Срабатывание охранной зоны',
'ZoneAlarmCancel': 'Восстановление охранной зоны',
'OTFail': 'Авария OpenTherm',
'OTLost': 'Потеря связи OpenTherm',
'OTFound': 'Восстановление связи OpenTherm',
'UploadSuccess': 'Файлы загружены',
'UploadFailed': 'Не удалось загрузить файлы',
'UserEvent': 'Пользовательское событие',
'UserAlarm': 'Пользовательская тревога',
'ExtSensorWarning': 'Предупреждение доп. датчика',
'ExtSensorAlarm': 'Тревога доп. датчика',
'CurrentECUErrors': 'Текущие ошибки ЭБУ',
'SavedECUErrors': 'Сохранённые ошибки ЭБУ',
}
REASON_RU = {
'fob': 'брелок',
'call': 'голосовое меню',
'sms': 'SMS',
'app': 'приложение/веб',
'autoguard': 'автопостановка',
'centrallock': 'центральный замок',
'handsfree': 'радиометка',
'pin': 'ПИН-код',
'manual': 'вручную',
'temperature': 'по температуре',
'timer': 'по таймеру',
'schedule': 'по расписанию',
}
RESULT_RU = {
'success': 'успешно',
'fail': 'неудачно',
'breakdown': 'двигатель заглох',
}
OT_FAIL_FLAGS_RU = {
'sr': 'требуется обслуживание',
'lr': 'требуется ручной сброс',
'wp': 'низкое давление воды',
'gf': 'сбой газа/горелки',
'ap': 'сбой давления воздуха',
'wot': 'перегрев воды',
}
# --- Аутентификация ---
def get_auth_token():
global auth_token
@@ -128,8 +265,9 @@ def get_auth_token():
logging.error(f"Exception while getting auth token: {e}")
# --- Справочники имён ---
def build_name_cache(device):
"""Собираем человекочитаемые имена для ID объектов."""
cache = {}
z3k = device.get('z3k_config', {})
@@ -149,21 +287,170 @@ def build_name_cache(device):
def build_analog_input_types(device):
"""Возвращает {input_id: sensor_type} для всех аналоговых входов."""
result = {}
for ai in device.get('z3k_config', {}).get('analog_inputs', []):
result[ai['id']] = ai.get('sensor_type')
return result
GSM_STATE_MAP = {
'not-registered': 0,
'home-network': 1,
'searching': 2,
'rejected': 3,
'roaming': 5,
}
# --- Описание событий ---
def describe_event(ev_type, data):
if not data:
return ''
parts = []
if 'reason' in data:
parts.append(f"причина: {REASON_RU.get(data['reason'], data['reason'])}")
if 'fob_number' in data:
parts.append(f"брелок №{data['fob_number']}")
if 'phonenumber' in data:
parts.append(f"тел.: {data['phonenumber']}")
if 'number' in data:
parts.append(f"тел.: {data['number']}")
if 'message' in data:
parts.append(str(data['message']))
if 'zone' in data:
parts.append(f"зона: {data['zone']}")
if 'slot' in data:
parts.append(f"слот: {data['slot']}")
if 'type' in data and ev_type == 'ThermometerUpdate':
parts.append(f"тип: {data['type']}")
if 'serial' in data:
parts.append(f"SN: {data['serial']}")
if 'voltage' in data:
parts.append(f"{data['voltage']} В")
if 'result' in data:
parts.append(f"результат: {RESULT_RU.get(data['result'], data['result'])}")
if 'text' in data:
parts.append(f"текст: {data['text']}")
if 'label' in data:
parts.append(f"метка: {data['label']}")
if 'firmware_version' in data:
parts.append(f"прошивка: {data['firmware_version']}")
if 'profile_version' in data:
parts.append(f"профиль: {data['profile_version']}")
if ev_type == 'OTFail':
flags = data.get('f', []) or []
c = data.get('c', 0)
if c:
parts.append(f"код: {c}")
if flags:
parts.append("флаги: " + ", ".join(OT_FAIL_FLAGS_RU.get(f, f) for f in flags))
if ev_type in ('CurrentECUErrors', 'SavedECUErrors'):
errs = data.get('errors', [])
if errs:
parts.append("коды: " + ", ".join(str(e) for e in errs))
return '; '.join(parts)
# --- Loki ---
def push_to_loki(streams):
if not LOKI_ENABLED or not streams:
return
try:
r = requests.post(
LOKI_URL,
json={'streams': streams},
headers={'Content-Type': 'application/json'},
timeout=10
)
r.raise_for_status()
logging.debug(f"Pushed {sum(len(s['values']) for s in streams)} log lines to Loki")
except Exception as e:
logging.error(f"Loki push failed: {e}")
def collect_events(did):
global _last_event_poll_time
if not LOKI_ENABLED:
return
now = int(time.time())
mintime = _last_event_poll_time or (now - EVENT_LOOKBACK)
payload = {
'device_id': DEVICE_ID,
'mintime': mintime,
'maxtime': now,
}
if EVENT_FILTER:
payload['only'] = list(EVENT_FILTER)
try:
resp = requests.post(
f"{API_BASE}/raw_events",
headers={
'X-ZONT-Client': ZONT_CLIENT,
'X-ZONT-Token': auth_token,
'Content-Type': 'application/json',
},
json=payload,
timeout=15
)
resp.raise_for_status()
data = resp.json()
if not data.get('ok'):
logging.error(f"raw_events error: {data.get('error_ui', data.get('error'))}")
return
events = data.get('events', [])
if len(events) > EVENT_MAX:
events = events[-EVENT_MAX:]
_last_event_poll_time = now
if not events:
return
streams_map = {}
for ev in events:
if len(ev) < 8:
continue
ev_id, ev_time, ev_type, lon, lat, duration, extra, is_alarm = ev[:8]
ev_type = ev_type or 'unknown'
description = describe_event(ev_type, extra or {})
type_ru = EVENT_TYPE_RU.get(ev_type, ev_type)
log_obj = {
'time': ev_time,
'type': ev_type,
'type_ru': type_ru,
'alarm': bool(is_alarm),
'description': description,
'lat': lat,
'lon': lon,
'duration': duration,
}
line = json.dumps(log_obj, ensure_ascii=False)
labels = {
'job': LOKI_JOB,
'device_id': did,
'event_type': ev_type,
'alarm': 'true' if is_alarm else 'false',
}
skey = tuple(sorted(labels.items()))
if skey not in streams_map:
streams_map[skey] = {'stream': labels, 'values': []}
streams_map[skey]['values'].append([str(ev_time * 1_000_000_000), line])
push_to_loki(list(streams_map.values()))
logging.info(f"Pushed {len(events)} events to Loki for device {DEVICE_ID}")
except Exception as e:
logging.error(f"Error collecting events: {e}")
# --- Сбор метрик ---
def collect_metrics():
global auth_token, _name_cache, _analog_input_types
@@ -191,6 +478,7 @@ def collect_metrics():
logging.error(f"API error: {data.get('error_ui', data.get('error'))}")
if data.get('error') in ('unauthorized', 'token_expired'):
auth_token = None
_last_event_poll_time = 0
return
devices = data.get('devices', [])
@@ -199,7 +487,6 @@ def collect_metrics():
logging.warning(f"Device {DEVICE_ID} not found in account.")
return
# Обновляем кэши
_name_cache = build_name_cache(device)
_analog_input_types = build_analog_input_types(device)
@@ -253,13 +540,12 @@ def collect_metrics():
if 'sensor_ok' in obj:
SENSOR_OK.labels(device_id=did, object_id=obj_id, name=name).set(1 if obj['sensor_ok'] else 0)
# Аналоговый вход для контроля напряжения (id=20550) — оставляем отдельную метрику
# Аналоговый вход для контроля напряжения
if isinstance(obj, dict) and 'voltage' in obj and obj_id == '20550':
if obj['voltage'] is not None:
ANALOG_VOLTAGE.labels(device_id=did, object_id=obj_id, name=name).set(obj['voltage'])
# Дискретные датчики (дверь/окно) — есть поля voltage и value,
# а в конфиге sensor_type входит в DOOR_SENSOR_TYPES
# Дискретные датчики (дверь/окно)
if (isinstance(obj, dict) and 'value' in obj and 'voltage' in obj
and _analog_input_types.get(int(obj_id)) in DOOR_SENSOR_TYPES):
if obj.get('voltage') is not None:
@@ -270,7 +556,7 @@ def collect_metrics():
door_value = 1 - door_value
DOOR_OPEN.labels(device_id=did, input_id=obj_id, name=name).set(door_value)
# Охранные зоны (есть поле onguard)
# Охранные зоны
if isinstance(obj, dict) and 'onguard' in obj:
GUARD_ZONE_ONGUARD.labels(device_id=did, zone_id=obj_id, name=name).set(
1 if obj['onguard'] else 0
@@ -304,15 +590,28 @@ def collect_metrics():
logging.info(f"Metrics collected successfully for device {DEVICE_ID}")
# --- События -> Loki ---
collect_events(did)
except Exception as e:
logging.error(f"Error during metric collection: {e}")
# --- Запуск ---
if __name__ == '__main__':
if not all([ZONT_LOGIN, ZONT_PASSWORD, DEVICE_ID]):
logging.critical("Missing required env vars: ZONT_LOGIN, ZONT_PASSWORD, ZONT_DEVICE_ID")
exit(1)
if LOKI_ENABLED:
logging.info(f"Loki push enabled: {LOKI_URL}")
else:
if LOKI_URL and not env_bool('ZONT_LOKI_ENABLED', True):
logging.info("Loki push disabled by ZONT_LOKI_ENABLED=false")
else:
logging.info("Loki push disabled (ZONT_LOKI_URL not set)")
get_auth_token()
start_http_server(EXPORTER_PORT)
logging.info(f"Prometheus exporter started on port {EXPORTER_PORT}")