diff --git a/readme.md b/readme.md index 7024705..cdc1e88 100644 --- a/readme.md +++ b/readme.md @@ -1,135 +1,218 @@ # ZONT Prometheus Exporter -Экспортер метрик для устройств **ZONT** и **Mega SX** в формате, совместимом с **Prometheus**. Позволяет собирать данные о температуре, состоянии котла, уровне сигнала, охране и других параметрах через официальный API ZONT. +Экспортер телеметрии отопительных контроллеров **ZONT** (H-1V, H-1V.02, H1V02_PRO, H-1000, H-2000 и др.) в формате Prometheus. -## 📋 Возможности +Собирает текущее состояние устройства через облачное API `my.zont.online` и отдаёт метрики на HTTP-эндпоинт `/metrics`. Подходит для мониторинга в Grafana, алертинга в Alertmanager и любого стека, совместимого с Prometheus. -- 🔐 Аутентификация через официальный API ZONT (`my.zont.online/api`) -- 🌡️ Сбор температуры с проводных и радиодатчиков -- 🔥 Мониторинг работы котла (время работы, аварии) -- 📶 Уровень сигнала GSM/Wi-Fi -- 🛡️ Состояние охраны и сирены -- 🚗 Данные об автомобиле (ZTC): зажигание, двигатель, автозапуск -- 🐳 Готов к запуску в Docker -- ⚙️ Полностью настраивается через переменные окружения -- 🩺 Встроенный healthcheck +--- -## 🚀 Быстрый старт +## 📋 Содержание -### 1. Клонирование проекта +- [Возможности](#-возможности) +- [Требования](#-требования) +- [Быстрый старт](#-быстрый-старт) +- [Переменные окружения](#-переменные-окружения) +- [Метрики](#-метрики) +- [Интеграция с Prometheus](#-интеграция-с-prometheus) +- [Пример алертов](#-пример-алертов) +- [Диагностика](#-диагностика) +- [Ограничения](#-ограничения) +- [Безопасность](#-безопасность) +- [Лицензия](#-лицензия) + +--- + +## 🚀 Возможности + +- **Текущее состояние контроллера**: онлайн-статус, напряжение питания, память, источник питания. +- **Связь**: уровень GSM, состояние регистрации в сети, RSSI Wi-Fi, наличие связи с сервером ZONT. +- **Температуры**: проводные и аналоговые датчики (в т.ч. «Погода из интернета»), статус исправности. +- **Отопление**: целевая и заданная температура по каждому отопительному контуру, время работы. +- **Котёл / OpenTherm**: расчётная температура теплоносителя, уставка ГВС, наличие связи с котлом, аварии. +- **Погода**: температура с внешнего источника. +- **Аналоговые входы**: напряжение на входах (например, контроль питания). + +Работает как отдельный контейнер, не требует установки чего-либо на хост. + +--- + +## ✅ Требования + +- Docker (или Docker Compose) +- Аккаунт в личном кабинете [my.zont.online](https://my.zont.online) с добавленным устройством ZONT +- Сетевая доступность `my.zont.online:443` из контейнера +- Prometheus (опционально) для сбора метрик + +--- + +## 🏁 Быстрый старт + +### 1. Создайте файлы + +Вам нужны два файла: + +- `zont_exporter.py` — код экспортера +- `Dockerfile` — сборка образа + +### 2. Соберите образ ```bash -git clone <ваш-репозиторий>/zont-exporter.git -cd zont-exporter +docker build -t zont-exporter . ``` -### 2. Настройка окружения - -Создайте файл `.env` на основе примера и заполните своими данными: +### 3. Запустите контейнер ```bash -cp .env.example .env -nano .env -``` - -Пример содержимого `.env`: - -```env -ZONT_LOGIN=ваш_логин -ZONT_PASSWORD=ваш_пароль -ZONT_CLIENT_EMAIL=ваш@email.com -SCRAPE_INTERVAL=60 -EXPORTER_PORT=8000 -LOG_LEVEL=INFO -``` - -> ⚠️ **Важно**: `ZONT_CLIENT_EMAIL` — это контактный email, который вы указали при регистрации в ZONT. Он передаётся в заголовке `X-ZONT-Client` и используется производителем для связи с вами при изменениях в API. - -### 3. Запуск - -#### Вариант A: Docker Compose (рекомендуется) - -```bash -docker compose up -d --build -``` - -#### Вариант B: Docker build + run - -```bash -docker build -t zont-exporter:latest . - docker run -d \ --name zont-exporter \ --restart unless-stopped \ - --env-file .env \ - -p 8000:8000 \ - zont-exporter:latest + -p 9101:9101 \ + -e ZONT_LOGIN='your_login' \ + -e ZONT_PASSWORD='your_password' \ + -e ZONT_DEVICE_ID='554007' \ + -e ZONT_CLIENT='you@example.com' \ + zont-exporter ``` -#### Вариант C: Локальный запуск (без Docker) +### 4. Проверьте ```bash -pip install -r requirements.txt -export $(cat .env | xargs) -python zont_exporter.py +curl http://localhost:9101/metrics | grep zont_ ``` -### 4. Проверка работы - -```bash -# Логи -docker logs -f zont-exporter - -# Метрики -curl http://localhost:8000/metrics -``` - -В ответе вы должны увидеть метрики вида: +Вы должны увидеть метрики вида: ``` -# HELP zont_temperature_celsius Температура с датчиков ZONT -# TYPE zont_temperature_celsius gauge -zont_temperature_celsius{device_id="1580",device_name="Дом",sensor_name="Кухня"} 22.5 - -# HELP zont_boiler_working Работает ли котел (1 - да, 0 - нет) -# TYPE zont_boiler_working gauge -zont_boiler_working{device_id="1580",device_name="Дом"} 1 +zont_device_online{device_id="554007"} 1.0 +zont_voltage_volts{device_id="554007"} 12.1 +zont_gsm_level{device_id="554007"} 17.0 +zont_temperature_celsius{device_id="554007",name="Температура гаража 150",object_id="8200"} 18.8 +zont_boiler_connected{adapter_id="4096",device_id="554007"} 0.0 ``` -## 📊 Доступные метрики +--- -| Метрика | Тип | Описание | Метки | +## 🔧 Переменные окружения + +| Переменная | Обязательна | По умолчанию | Описание | |---|---|---|---| -| `zont_temperature_celsius` | Gauge | Температура с датчиков | `device_id`, `device_name`, `sensor_name` | -| `zont_boiler_working` | Gauge | Работа котла за последнюю минуту (1/0) | `device_id`, `device_name` | -| `zont_signal_level` | Gauge | Уровень сигнала GSM или Wi-Fi | `device_id`, `device_name`, `type` | -| `zont_guard_state` | Gauge | Состояние охраны (1 — включена, 0 — выключена) | `device_id`, `device_name` | +| `ZONT_LOGIN` | ✅ | — | Логин от личного кабинета `my.zont.online` | +| `ZONT_PASSWORD` | ✅ | — | Пароль от личного кабинета | +| `ZONT_DEVICE_ID` | ✅ | — | Числовой ID устройства (см. ниже, как узнать) | +| `ZONT_CLIENT` | ❌ | `my_exporter@example.com` | Контактный email для заголовка `X-ZONT-Client`. ZONT использует его для связи при изменениях API. Укажите свой. | +| `SCRAPE_INTERVAL` | ❌ | `60` | Интервал сбора метрик, секунды. Не имеет смысла ставить меньше 30 — API не успевает обновлять данные. | +| `EXPORTER_PORT` | ❌ | `9101` | Порт, на котором экспортер слушает `/metrics` внутри контейнера | +| `LOG_LEVEL` | ❌ | `INFO` | Уровень логирования: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. Регистр не важен. | -## 🔗 Интеграция с Prometheus +### Как узнать `ZONT_DEVICE_ID` -Добавьте job в ваш `prometheus.yml`: +**Способ 1. Через API (рекомендуется)** + +```bash +curl -s -X POST 'https://my.zont.online/api/devices' \ + -u 'login:password' \ + -H 'X-ZONT-Client: you@example.com' \ + -H 'Content-Type: application/json' \ + -d '{"load_io": true}' | python3 -m json.tool | grep -E '"id"|"name"|"serial"' +``` + +В ответе найдите блок своего устройства — поле `"id"` и есть `ZONT_DEVICE_ID`. + +**Способ 2. Через личный кабинет** + +Откройте устройство в веб-интерфейсе `my.zont.online` — ID обычно видно в URL страницы устройства. + +### Пример запуска с нестандартным портом и отладкой + +```bash +docker run -d \ + --name zont-exporter \ + --restart unless-stopped \ + -p 9200:9200 \ + -e ZONT_LOGIN='your_login' \ + -e ZONT_PASSWORD='your_password' \ + -e ZONT_DEVICE_ID='554007' \ + -e ZONT_CLIENT='you@example.com' \ + -e EXPORTER_PORT='9200' \ + -e LOG_LEVEL='DEBUG' \ + zont-exporter +``` + +> ⚠️ Номера портов в `-p <хост>:<контейнер>` и `EXPORTER_PORT` должны совпадать по контейнерной части. Пример выше: `EXPORTER_PORT=9200` и `-p 9200:9200`. Если хост-порт занят — используйте `-p 8080:9200`. + +--- + +## 📊 Метрики + +Все метрики имеют префикс `zont_` и label `device_id`. Для удобства читаемые имена объектов (датчиков, контуров) добавляются как label `name`. + +### Общие + +| Метрика | Тип | Labels | Описание | +|---|---|---|---| +| `zont_device_online` | gauge | `device_id` | 1 — устройство на связи, 0 — offline | +| `zont_device_last_receive_time` | gauge | `device_id` | Unix-время последнего пакета от устройства | +| `zont_voltage_volts` | gauge | `device_id` | Напряжение основного питания, В | +| `zont_power_source` | gauge | `device_id` | 1 — основное питание, 0 — иное | +| `zont_memory_used_percent` | gauge | `device_id` | Использование памяти контроллера, % | +| `zont_memory_used_bytes` | gauge | `device_id` | Использование памяти, байт | +| `zont_internet_weather_celsius` | gauge | `device_id` | Температура с внешнего источника погоды, °C | + +### Связь + +| Метрика | Тип | Labels | Описание | +|---|---|---|---| +| `zont_gsm_level` | gauge | `device_id` | Уровень сигнала GSM, 0–31 | +| `zont_gsm_state` | gauge | `device_id` | 0=not-registered, 1=home-network, 2=searching, 3=rejected, 5=roaming | +| `zont_wifi_rssi` | gauge | `device_id` | Уровень сигнала Wi-Fi | +| `zont_server_connected` | gauge | `device_id` | 1 — есть связь с сервером ZONT | + +### Датчики температуры и аналоговые входы + +| Метрика | Тип | Labels | Описание | +|---|---|---|---| +| `zont_temperature_celsius` | gauge | `device_id`, `object_id`, `name` | Текущая температура, °C | +| `zont_temperature_sensor_ok` | gauge | `device_id`, `object_id`, `name` | 1 — датчик исправен | +| `zont_analog_voltage_volts` | gauge | `device_id`, `object_id`, `name` | Напряжение на аналоговом входе, В | + +### Отопление + +| Метрика | Тип | Labels | Описание | +|---|---|---|---| +| `zont_heating_target_temp_celsius` | gauge | `device_id`, `circuit_id`, `name` | Фактическая целевая температура контура | +| `zont_heating_setpoint_temp_celsius` | gauge | `device_id`, `circuit_id`, `name` | Заданная температура (уставка) | +| `zont_heating_worktime_seconds` | gauge | `device_id`, `circuit_id`, `name` | Время работы контура за последнюю минуту, сек | +| `zont_heating_status` | gauge | `device_id`, `circuit_id`, `name` | Код статуса контура | + +### Котёл / OpenTherm + +| Метрика | Тип | Labels | Описание | +|---|---|---|---| +| `zont_boiler_cs_celsius` | gauge | `device_id`, `adapter_id` | Расчётная температура теплоносителя, °C | +| `zont_boiler_ds_celsius` | gauge | `device_id`, `adapter_id` | Уставка температуры ГВС, °C | +| `zont_boiler_connected` | gauge | `device_id`, `adapter_id` | 1 — котёл на связи, 0 — нет | +| `zont_boiler_fail` | gauge | `device_id`, `adapter_id` | 1 — авария котла | + +--- + +## 🔌 Интеграция с Prometheus + +Добавьте в `prometheus.yml`: ```yaml scrape_configs: - job_name: 'zont' scrape_interval: 60s + scrape_timeout: 20s static_configs: - - targets: ['zont-exporter:8000'] + - targets: ['zont-exporter:9101'] + labels: + instance: 'home-boiler' ``` -Если Prometheus запущен на хосте (вне Docker), используйте `host.docker.internal` или IP вашего сервера: +Если Prometheus работает в том же Docker-сети, используйте имя контейнера. Если на хосте — `host.docker.internal:9101` (Windows/macOS) или IP хоста (Linux). -```yaml -scrape_configs: - - job_name: 'zont' - scrape_interval: 60s - static_configs: - - targets: ['host.docker.internal:8000'] -``` - -## 📈 Полный стек мониторинга - -Если у вас ещё нет Prometheus и Grafana, используйте готовый `docker-compose.yml`: +### Docker Compose (пример) ```yaml services: @@ -137,163 +220,174 @@ services: build: . container_name: zont-exporter restart: unless-stopped - env_file: - - .env - networks: - - monitoring - - prometheus: - image: prom/prometheus:latest - container_name: prometheus - restart: unless-stopped - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus-data:/prometheus ports: - - "9090:9090" - networks: - - monitoring - - grafana: - image: grafana/grafana:latest - container_name: grafana - restart: unless-stopped - volumes: - - grafana-data:/var/lib/grafana - ports: - - "3000:3000" - networks: - - monitoring - -networks: - monitoring: - driver: bridge - -volumes: - prometheus-data: - grafana-data: + - "9101:9101" + environment: + ZONT_LOGIN: "your_login" + ZONT_PASSWORD: "your_password" + ZONT_DEVICE_ID: "554007" + ZONT_CLIENT: "you@example.com" + SCRAPE_INTERVAL: "60" + EXPORTER_PORT: "9101" + LOG_LEVEL: "INFO" ``` -После запуска: - -- **Prometheus**: http://localhost:9090 -- **Grafana**: http://localhost:3000 (логин/пароль по умолчанию: `admin`/`admin`) - -## ⚙️ Конфигурация - -Все параметры задаются через переменные окружения: - -| Переменная | Обязательна | По умолчанию | Описание | -|---|---|---|---| -| `ZONT_LOGIN` | ✅ | — | Логин от личного кабинета ZONT | -| `ZONT_PASSWORD` | ✅ | — | Пароль от личного кабинета ZONT | -| `ZONT_CLIENT_EMAIL` | ❌ | `exporter@localhost` | Контактный email (заголовок `X-ZONT-Client`) | -| `ZONT_API_URL` | ❌ | `https://my.zont.online/api` | Базовый URL API | -| `SCRAPE_INTERVAL` | ❌ | `60` | Интервал опроса в секундах | -| `EXPORTER_PORT` | ❌ | `8000` | Порт HTTP-сервера экспортера | -| `LOG_LEVEL` | ❌ | `INFO` | Уровень логирования (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | - -## 🐳 Сборка Docker-образа - -```bash -# Сборка -docker build -t zont-exporter:latest . - -# Запуск с проверкой переменных -docker run --rm --env-file .env zont-exporter:latest -``` - -### Healthcheck - -Docker автоматически проверяет доступность эндпоинта `/metrics` каждые 30 секунд. Если экспортер перестанет отвечать, контейнер будет помечен как `unhealthy`. - -Проверить статус: - -```bash -docker inspect --format='{{.State.Health.Status}}' zont-exporter -``` - -## 🔧 Расширение - -### Добавление новых метрик - -API ZONT предоставляет много данных. Чтобы добавить новую метрику, откройте `zont_exporter.py` и расширьте метод `collect()`: - -```python -# Пример: сбор пользовательских статусов (custom_controls_state) -custom_gauge = GaugeMetricFamily( - 'zont_custom_status', - 'Пользовательский статус', - labels=['device_id', 'device_name', 'status_id'] -) - -for device in devices: - custom_state = device.get('io', {}).get('custom_controls_state', {}) - for status_id, value in custom_state.items(): - custom_gauge.add_metric( - [str(device['id']), device['name'], str(status_id)], - value - ) - -yield custom_gauge -``` - -### Полезные разделы API - -Согласно [официальной документации](https://lk.zont-online.ru/api/docs/), для расширения доступны: - -- `last-boiler-state` — детальное состояние котла (OpenTherm, модуляция, давление) -- `custom_controls_state` — пользовательские статусы (ZTC-7xx, Mega SX) -- `ztc_state` — состояние контроллера (баланс SIM, напряжение, статус-флаги) -- `temperature` — история температур через `load_data` - -## 🐛 Отладка - -### Экспортер не запускается - -Проверьте логи: - -```bash -docker logs zont-exporter -``` - -Частые причины: - -- Не заданы `ZONT_LOGIN` или `ZONT_PASSWORD` -- Неверный логин/пароль -- Отсутствует доступ к `my.zont.online` - -### Метрики пустые - -- Убедитесь, что к вашему аккаунту ZONT привязаны устройства. -- Проверьте `ZONT_CLIENT_EMAIL` — некоторые методы требуют корректного контакта. -- Увеличьте `LOG_LEVEL=DEBUG` для детальной диагностики. - -### Проблемы с токеном - -Экспортер автоматически обновляет токен при получении `403`. Если проблема повторяется: - -- Убедитесь, что пароль не содержит спецсимволов, требующих экранирования. -- Проверьте, не отозван ли токен в личном кабинете ZONT. - -## 📚 Документация API ZONT - -- Официальная документация: https://lk.zont-online.ru/api/docs/ -- Облегчённое Widget API v3: https://my.zont.online/api/widget/v3 -- Сайт производителя: https://zont.online - -## 📄 Лицензия - -MIT - -## 🤝 Вклад - -Pull request'ы приветствуются. Для крупных изменений сначала откройте issue для обсуждения. - --- -**Полезные ссылки:** +## 🚨 Пример алертов -- [Prometheus](https://prometheus.io/) -- [Grafana](https://grafana.com/) -- [prometheus-client (Python)](https://github.com/prometheus/client_python) \ No newline at end of file +`prometheus/rules/zont.yml`: + +```yaml +groups: + - name: zont + rules: + - alert: ZontDeviceOffline + expr: zont_device_online == 0 + for: 10m + labels: + severity: critical + annotations: + summary: "ZONT {{ $labels.device_id }} offline" + description: "Устройство не выходит на связь более 10 минут." + + - alert: ZontServerDisconnected + expr: zont_server_connected == 0 + for: 5m + labels: + severity: warning + annotations: + summary: "ZONT {{ $labels.device_id }} потерял связь с сервером" + + - alert: ZontBoilerDisconnected + expr: zont_boiler_connected == 0 + for: 15m + labels: + severity: warning + annotations: + summary: "ZONT {{ $labels.device_id }}: нет связи с котлом (адаптер {{ $labels.adapter_id }})" + + - alert: ZontBoilerFail + expr: zont_boiler_fail == 1 + for: 5m + labels: + severity: critical + annotations: + summary: "ZONT {{ $labels.device_id }}: авария котла" + + - alert: ZontLowVoltage + expr: zont_voltage_volts < 10 + for: 5m + labels: + severity: warning + annotations: + summary: "ZONT {{ $labels.device_id }}: низкое напряжение питания {{ $value }} В" + + - alert: ZontWeakGSM + expr: zont_gsm_level < 5 + for: 15m + labels: + severity: warning + annotations: + summary: "ZONT {{ $labels.device_id }}: слабый сигнал GSM ({{ $value }})" + + - alert: ZontHighMemory + expr: zont_memory_used_percent > 85 + for: 30m + labels: + severity: info + annotations: + summary: "ZONT {{ $labels.device_id }}: высокая загрузка памяти {{ $value }}%" +``` + +--- + +## 🩺 Диагностика + +### Метрики пустые или `/metrics` отдаёт только `python_*` + +1. Проверьте логи: + ```bash + docker logs zont-exporter --tail 100 + ``` +2. Если непонятно, что происходит, включите подробное логирование: + ```bash + docker run -d --name zont-exporter \ + -p 9101:9101 \ + -e ZONT_LOGIN='...' -e ZONT_PASSWORD='...' \ + -e ZONT_DEVICE_ID='554007' \ + -e ZONT_CLIENT='you@example.com' \ + -e LOG_LEVEL='DEBUG' \ + zont-exporter + ``` + Затем смотрите логи: + ```bash + docker logs zont-exporter --tail 200 + ``` + В режиме `DEBUG` видны детали ответов API и структура `io`-объектов. + +3. Ищите в логах строки: + - `Successfully obtained ZONT auth token.` — токен получен. + - `Failed to get auth token: ...` — проблема с логином/паролем. + - `API error: ...` — API вернул ошибку. + - `Device 554007 not found in account.` — неверный `ZONT_DEVICE_ID`. + - `Metrics collected successfully for device ...` — всё ок. + +### Ошибка `SyntaxError: name 'auth_token' is used prior to global declaration` + +Объявление `global auth_token` должно стоять **в самом начале** функции, до любого использования переменной. В актуальной версии кода это уже учтено. + +### Ошибка аутентификации + +Проверьте логин/пароль руками: + +```bash +curl -s -X POST 'https://my.zont.online/api/get_authtoken' \ + -u 'login:password' \ + -H 'X-ZONT-Client: you@example.com' \ + -H 'Content-Type: application/json' \ + -d '{"client_name": "debug"}' | python3 -m json.tool +``` + +Ожидаемый ответ: `{"ok": true, "token": "..."}`. + +### Хочу больше данных (история, а не текущее состояние) + +Метод `devices` отдаёт **снимок текущего состояния**. Историю (графики за период) можно получить через метод `load_data` с типами `z3k_temperature`, `z3k_heating_circuit`, `z3k_boiler_adapter`, `ztc_state`. Для этого требуется отдельная логика — при необходимости расширьте экспортер. + +--- + +## ⚠️ Ограничения + +- Экспортер собирает **только текущее состояние**, не историю. Для графиков используйте `load_data` (см. выше) или смотрите графики в личном кабинете ZONT. +- Работает только с **устройствами, привязанными к аккаунту my.zont.online**. Локальный доступ к контроллеру по USB/локальной сети не используется. +- Тип `thermostat_work` через `load_data` для модели **H1V02_PRO** (H-1V.02) возвращает пустые массивы. Используйте данные из `devices?load_io=true` — там вся актуальная телеметрия. +- Набор доступных полей в `io.z3k-state` зависит от модели, прошивки и конфигурации. Экспортер игнорирует отсутствующие поля — это нормально. +- Требуется HTTPS-доступ к `my.zont.online`. При использовании прокси задайте `HTTPS_PROXY` в окружении контейнера. + +--- + +## 🔒 Безопасность + +- **Не храните логин/пароль в открытом виде в Docker Compose / systemd unit.** Используйте: + - Docker secrets, + - переменные окружения из защищённого файла (`--env-file`), + - внешний секрет-менеджер (Vault, SOPS, Ansible Vault). +- **Заведите отдельного пользователя** в ZONT для API-интеграции. Не используйте основную учётную запись владельца. +- **Ротируйте пароль**, если он где-то засветился (переписка, скриншоты, git-история). +- Экспортер **не пишет** логин/пароль в логи, но может логировать ответ API. Проверьте уровень логирования, если ответы содержат чувствительные данные. +- Токен ZONT (`X-ZONT-Token`) хранится только в памяти процесса и перезапрашивается при истечении (`unauthorized` / `token_expired`). +- Эндпоинт `/metrics` **не имеет аутентификации**. Не выставляйте его в интернет. Используйте network policy / firewall. + +--- + +## 📄 Лицензия + +MIT. Используйте на свой риск. Проект не связан с ООО «Микро Лайн» / ZONT — это независимая интеграция с публичным API. + +--- + +## 🙏 Благодарности + +- Команде ZONT за публичное API и [документацию](https://my.zont.online/api). +- Сообществу Prometheus за `prometheus_client`. \ No newline at end of file diff --git a/zont-exporter/zont_exporter.py b/zont-exporter/zont_exporter.py index b833969..6d5db68 100644 --- a/zont-exporter/zont_exporter.py +++ b/zont-exporter/zont_exporter.py @@ -9,11 +9,11 @@ Mega SX, ZTC и др.) в формате Prometheus. Переменные окружения: ZONT_LOGIN — логин от my.zont.online (обязательно) ZONT_PASSWORD — пароль (обязательно, если не задан ZONT_TOKEN) - ZONT_DEVICE_ID — ID устройства (0 = взять первое; по умолчанию 0) + ZONT_DEVICE_ID — ID устройства ZONT_EMAIL — значение заголовка X-ZONT-Client (по умолчанию exporter@example.com) ZONT_TOKEN — готовый auth-токен (опционально) ZONT_POLL_INTERVAL — интервал опроса, сек (по умолчанию 60) - EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9877) + EXPORTER_PORT — порт HTTP-сервера метрик (по умолчанию 9101) LOG_LEVEL — DEBUG/INFO/WARNING/ERROR (по умолчанию INFO) """ import os @@ -22,27 +22,64 @@ import logging import requests from prometheus_client import start_http_server, Gauge -# Настройка логирования logging.basicConfig(level=logging.INFO, 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')) API_BASE = "https://my.zont.online/api" SCRAPE_INTERVAL = int(os.getenv('SCRAPE_INTERVAL', 60)) +EXPORTER_PORT = int(os.getenv('EXPORTER_PORT', 9101)) +LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() -# Определение метрик Prometheus -TEMP_GAUGE = Gauge('zont_temperature_celsius', 'Current temperature', ['device_id', 'sensor_name', 'sensor_type']) -BOILER_WORK = Gauge('zont_boiler_work_seconds', 'Boiler work time in last minute', ['device_id']) -TARGET_TEMP = Gauge('zont_target_temperature_celsius', 'Target temperature', ['device_id']) +# --- Метрики Prometheus --- + +# Общие +DEVICE_ONLINE = Gauge('zont_device_online', 'Device online status (1=online, 0=offline)', ['device_id']) +DEVICE_LAST_SEEN = Gauge('zont_device_last_receive_time', 'Unix timestamp of last data from device', ['device_id']) +VOLTAGE = Gauge('zont_voltage_volts', 'Supply voltage in volts', ['device_id']) +POWER_SOURCE = Gauge('zont_power_source', 'Power source (1=main, 0=other)', ['device_id']) + +# GSM / WiFi +GSM_LEVEL = Gauge('zont_gsm_level', 'GSM signal level 0-31', ['device_id']) +GSM_STATE = Gauge('zont_gsm_state', 'GSM state (0=not-registered,1=home-network,2=searching,3=rejected,5=roaming)', ['device_id']) +WIFI_RSSI = Gauge('zont_wifi_rssi', 'WiFi RSSI', ['device_id']) +SERVER_CONNECTED = Gauge('zont_server_connected', 'Connected to ZONT server (1=yes)', ['device_id']) + +# Память +MEMORY_PERCENT = Gauge('zont_memory_used_percent', 'Memory usage percent', ['device_id']) +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']) + +# Напряжение аналогового входа +ANALOG_VOLTAGE = Gauge('zont_analog_voltage_volts', 'Analog input voltage', ['device_id', 'object_id', 'name']) + +# Отопление +HEATING_TARGET_TEMP = Gauge('zont_heating_target_temp_celsius', 'Heating circuit target temperature', ['device_id', 'circuit_id', 'name']) +HEATING_SETPOINT_TEMP = Gauge('zont_heating_setpoint_temp_celsius', 'Heating circuit setpoint temperature', ['device_id', 'circuit_id', 'name']) +HEATING_WORKTIME = Gauge('zont_heating_worktime_seconds', 'Heating circuit work time in last minute', ['device_id', 'circuit_id', 'name']) +HEATING_STATUS = Gauge('zont_heating_status', 'Heating circuit status code', ['device_id', 'circuit_id', 'name']) + +# Котёл / OpenTherm +BOILER_CS = Gauge('zont_boiler_cs_celsius', 'Boiler calculated CH water temp', ['device_id', 'adapter_id']) +BOILER_DS = Gauge('zont_boiler_ds_celsius', 'Boiler DHW setpoint', ['device_id', 'adapter_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']) + +# Справочники ID -> имя для читаемых меток +_name_cache = {} -# Глобальная переменная для токена auth_token = None + def get_auth_token(): - """Получает токен аутентификации ZONT API.""" global auth_token try: resp = requests.post( @@ -62,109 +99,167 @@ def get_auth_token(): except Exception as e: logging.error(f"Exception while getting auth token: {e}") -def fetch_last_value(dta_list): - """Извлекает последнее значение из Delta-time array.""" - if not dta_list or not isinstance(dta_list, list): - return None - # Последний элемент массива имеет актуальное значение - last_entry = dta_list[-1] - if isinstance(last_entry, list) and len(last_entry) >= 2: - return last_entry[1] - return None + +def build_name_cache(device): + """Собираем человекочитаемые имена для ID объектов.""" + cache = {} + z3k = device.get('z3k_config', {}) + + for s in z3k.get('wired_temperature_sensors', []): + cache[s['id']] = s.get('name') or f"wired_{s['id']}" + for s in z3k.get('analog_temperature_sensors', []): + cache[s['id']] = s.get('name') or f"analog_{s['id']}" + for s in z3k.get('analog_inputs', []): + cache[s['id']] = s.get('name') or f"input_{s['id']}" + for hc in z3k.get('heating_circuits', []): + cache[hc['id']] = hc.get('name') or f"hc_{hc['id']}" + for b in z3k.get('boiler_adapters', []): + cache[b['id']] = b.get('name') or f"boiler_{b['id']}" + return cache + + +GSM_STATE_MAP = { + 'not-registered': 0, + 'home-network': 1, + 'searching': 2, + 'rejected': 3, + 'roaming': 5, +} + def collect_metrics(): - """Основная функция сбора метрик.""" - global auth_token + global auth_token, _name_cache if not auth_token: - logging.warning("No auth token available, trying to authenticate...") get_auth_token() if not auth_token: return - now = int(time.time()) - mintime = now - 3600 - - payload = { - "requests": [{ - "device_id": DEVICE_ID, - "data_types": ["thermostat_work"], - "mintime": mintime, - "maxtime": now - }] - } - try: resp = requests.post( - f"{API_BASE}/load_data", + f"{API_BASE}/devices", headers={ 'X-ZONT-Client': ZONT_CLIENT, 'X-ZONT-Token': auth_token, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, - json=payload, + json={'load_io': True}, timeout=15 ) resp.raise_for_status() data = resp.json() if not data.get('ok'): - logging.error(f"API returned error: {data.get('error_ui', data.get('error'))}") - if data.get('error') in ['unauthorized', 'token_expired']: + logging.error(f"API error: {data.get('error_ui', data.get('error'))}") + if data.get('error') in ('unauthorized', 'token_expired'): auth_token = None return - responses = data.get('responses', []) - if not responses: - logging.warning("Empty responses from API.") + devices = data.get('devices', []) + device = next((d for d in devices if d.get('id') == DEVICE_ID), None) + if not device: + logging.warning(f"Device {DEVICE_ID} not found in account.") return - work_data = responses[0].get('thermostat_work') - if not work_data: - logging.info("No thermostat_work data in response.") - return + # Обновляем кэш имён + _name_cache = build_name_cache(device) - boiler_work = fetch_last_value(work_data.get('boiler_work_time')) - if boiler_work is not None: - BOILER_WORK.labels(device_id=str(DEVICE_ID)).set(boiler_work) + did = str(DEVICE_ID) - target_temp = fetch_last_value(work_data.get('target_temp')) - if target_temp is not None: - TARGET_TEMP.labels(device_id=str(DEVICE_ID)).set(target_temp) + # --- Общие --- + DEVICE_ONLINE.labels(device_id=did).set(1 if device.get('online') else 0) + if device.get('last_receive_time'): + DEVICE_LAST_SEEN.labels(device_id=did).set(device['last_receive_time']) - temps = work_data.get('temperature') - if temps and isinstance(temps, dict): - for sensor_id, sensor_info in temps.items(): - if not isinstance(sensor_info, dict): - continue - name = sensor_info.get('name', sensor_id) - val = fetch_last_value(sensor_info.get('temperature')) - if val is not None: - TEMP_GAUGE.labels( - device_id=str(DEVICE_ID), - sensor_name=name, - sensor_type='thermostat_sensor' - ).set(val) + io = device.get('io', {}) + if 'voltage' in io: + VOLTAGE.labels(device_id=did).set(io['voltage']) + if io.get('power-source'): + POWER_SOURCE.labels(device_id=did).set(1 if io['power-source'] == 'main' else 0) + + # --- GSM / WiFi / server --- + gsm = io.get('gsm-state', {}) + if 'level' in gsm: + GSM_LEVEL.labels(device_id=did).set(gsm['level']) + if gsm.get('state'): + GSM_STATE.labels(device_id=did).set(GSM_STATE_MAP.get(gsm['state'], -1)) + + wifi = io.get('additional-wifi-state') or io.get('wifi-state') or {} + if 'rssi' in wifi: + WIFI_RSSI.labels(device_id=did).set(wifi['rssi']) + + conn = io.get('connection-state', {}) + SERVER_CONNECTED.labels(device_id=did).set(1 if conn.get('connected_to_server') else 0) + + # --- Память --- + mem = io.get('memory-use-state', {}) + if 'percents' in mem: + MEMORY_PERCENT.labels(device_id=did).set(mem['percents']) + if 'bytes' in mem: + MEMORY_BYTES.labels(device_id=did).set(mem['bytes']) + + # --- Погода --- + if 'internet_weather' in device: + INTERNET_WEATHER.labels(device_id=did).set(device['internet_weather']) + + # --- z3k-state --- + z3k_state = io.get('z3k-state', {}) + for obj_id, obj in z3k_state.items(): + name = _name_cache.get(int(obj_id), obj_id) if obj_id.isdigit() else obj_id + + # Температурные датчики + if isinstance(obj, dict) and 'curr_temp' in obj: + if obj['curr_temp'] is not None: + TEMP_CURR.labels(device_id=did, object_id=obj_id, name=name).set(obj['curr_temp']) + 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) + + # Аналоговый вход (напряжение) + 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']) + + # Отопительные контуры + if isinstance(obj, dict) and 'target_temp' in obj and 'worktime' in obj: + if obj.get('target_temp') is not None: + HEATING_TARGET_TEMP.labels(device_id=did, circuit_id=obj_id, name=name).set(obj['target_temp']) + if obj.get('setpoint_temp') is not None: + HEATING_SETPOINT_TEMP.labels(device_id=did, circuit_id=obj_id, name=name).set(obj['setpoint_temp']) + if obj.get('worktime') is not None: + HEATING_WORKTIME.labels(device_id=did, circuit_id=obj_id, name=name).set(obj['worktime']) + if obj.get('status') is not None: + HEATING_STATUS.labels(device_id=did, circuit_id=obj_id, name=name).set(obj['status']) + + # Адаптер котла / OpenTherm + if isinstance(obj, dict) and 'ot' in obj and 'status' in obj: + ot = obj.get('ot', {}) or {} + if ot.get('cs') is not None: + BOILER_CS.labels(device_id=did, adapter_id=obj_id).set(ot['cs']) + if ot.get('ds') is not None: + BOILER_DS.labels(device_id=did, adapter_id=obj_id).set(ot['ds']) + status = obj.get('status', {}) + BOILER_CONNECTED.labels(device_id=did, adapter_id=obj_id).set( + 0 if status.get('no_boiler_connection') else 1 + ) + BOILER_FAIL.labels(device_id=did, adapter_id=obj_id).set( + 1 if status.get('boiler_fail') else 0 + ) logging.info(f"Metrics collected successfully for device {DEVICE_ID}") 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 environment variables: ZONT_LOGIN, ZONT_PASSWORD, ZONT_DEVICE_ID") + logging.critical("Missing required env vars: ZONT_LOGIN, ZONT_PASSWORD, ZONT_DEVICE_ID") exit(1) - # Первичная аутентификация get_auth_token() + start_http_server(EXPORTER_PORT) # <-- было 9101 + logging.info(f"Prometheus exporter started on port {EXPORTER_PORT}") - # Запуск HTTP-сервера Prometheus на порту 9101 - start_http_server(9101) - logging.info("Prometheus exporter started on port 9101") - - # Основной цикл while True: collect_metrics() time.sleep(SCRAPE_INTERVAL) \ No newline at end of file