From 1994b943c9c63bfe33a3d27b34bbf86d0780e6db Mon Sep 17 00:00:00 2001 From: energys Date: Wed, 30 Sep 2026 12:02:28 +0900 Subject: [PATCH] Add loki --- zont-exporter/zont_exporter.py | 401 ++++++++++++++++++++++++++++----- 1 file changed, 350 insertions(+), 51 deletions(-) diff --git a/zont-exporter/zont_exporter.py b/zont-exporter/zont_exporter.py index a769a8a..93580d8 100644 --- a/zont-exporter/zont_exporter.py +++ b/zont-exporter/zont_exporter.py @@ -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}")