Данные инвертора Deye раз в минуту: локальный мониторинг без облака

Практика · Deye · Solarman V5 · Modbus · Synology · Laravel

Приложение Deye показывает данные с заметной задержкой. Я научился читать гибридный инвертор SUN-06K-SG05LP1-EU напрямую через штатный Wi-Fi-логгер Solarman, собирать телеметрию раз в минуту на Synology и отправлять её на свой Laravel-сервер с историей, API и графиками.

1 мининтервал сбора вместо облачного
0дополнительного оборудования
2 запросаполный снимок инвертора
~15 строкPython для первой проверки

Коротко: Wi-Fi-логгер Solarman, который работает вместе с Deye, слушает локальный TCP-порт 8899. Через него можно напрямую читать регистры инвертора по Modbus. Сборщик на домашнем Synology раз в минуту снимает показания и отправляет их на мой сервер.

В чём проблема облачного мониторинга

В проекте используется однофазный гибридный инвертор Deye SUN-06K-SG05LP1-EU на 6 кВт с аккумулятором и Wi-Fi-логгером Solarman. Штатная схема выглядит так:

Инвертор → Wi-Fi-логгер → облако Solarman/Deye → приложение

Логгер отправляет данные в облако пакетами с интервалом в несколько минут. После этого данные ещё должны обработаться и появиться в приложении. В результате пользователь нередко видит состояние пятиминутной давности или старше. Для просмотра дневной выработки этого достаточно, но для оперативного мониторинга и автоматизации — нет.

Для гибридного инвертора особенно важны быстрые события:

  • пропала сеть, и дом перешёл на батарею;
  • батарея разряжается быстрее ожидаемого;
  • возникла пиковая нагрузка;
  • сеть вернулась, и начался заряд.

Кроме того, история остаётся в чужом облаке. Без собственного API неудобно строить специальные графики, объединять данные с другими системами и отправлять уведомления в Telegram.

Что умеет штатный Wi-Fi-логгер

Solarman LSW-3/LSW-5 — небольшой мост между Wi-Fi и RS485. Внутри он общается с инвертором по Modbus RTU, отправляет данные в облако и одновременно слушает TCP-порт 8899 в локальной сети.

На этом порту работает протокол Solarman V5: Modbus-кадр оборачивается в заголовок с серийным номером логгера. Для Python существует готовая библиотека pysolarmanv5. Для подключения нужны:

  • локальный IP логгера — его можно найти в списке клиентов роутера;
  • серийный номер логгера — именно логгера, а не инвертора;
  • порт 8899 — доступность проверяется командой nc -z <ip> 8899.

Облако и приложение Deye продолжают работать как раньше. Я ничего не перепрошиваю и не отключаю, а лишь добавляю второго локального клиента.

Первая проверка: около 15 строк на Python

Перед разработкой полноценного контура я проверил, что логгер отвечает, а карта регистров подходит к модели инвертора. Ниже обезличенная версия рабочего deye_read.py: локальный IP и серийный номер нужно заменить своими значениями.

Показать диагностический скрипт deye_read.py
import sys
from pysolarmanv5 import PySolarmanV5

LOGGER_IP = "192.168.x.x"
LOGGER_SN = 29XXXXXXXX


def s16(v):
    return v - 0x10000 if v >= 0x8000 else v


def main():
    inv = PySolarmanV5(
        LOGGER_IP,
        LOGGER_SN,
        port=8899,
        mb_slave_id=1,
        socket_timeout=10,
    )
    try:
        a = inv.read_holding_registers(59, 42)   # 59..100
        b = inv.read_holding_registers(108, 84)  # 108..191
    finally:
        inv.disconnect()

    r = lambda n: a[n - 59] if n < 108 else b[n - 108]
    states = {
        0: "standby", 1: "self-check", 2: "normal",
        3: "alarm", 4: "fault",
    }

    print(f"Статус: {states.get(r(59), r(59))}")
    print(f"PV1: {r(109)*0.1:.1f} V  {r(110)*0.1:.1f} A  {r(186)} W")
    print(f"PV2: {r(111)*0.1:.1f} V  {r(112)*0.1:.1f} A  {r(187)} W")
    print(f"PV сегодня: {r(108)*0.1:.1f} kWh")
    print(f"SOC: {r(184)} %")
    print(f"Батарея: {r(183)*0.01:.2f} V, {s16(r(190))} W")
    print(f"Сеть: {r(150)*0.1:.1f} V, {r(79)*0.01:.2f} Hz")
    print(f"Импорт/экспорт: {s16(r(169))} W")
    print(f"Нагрузка: {r(178)} W")
    print(f"Темп. DC/AC: {(r(90)-1000)*0.1:.1f} / {(r(91)-1000)*0.1:.1f} °C")


if __name__ == "__main__":
    sys.exit(main())

Всё нужное состояние инвертора укладывается в два запроса. Читать каждый регистр отдельно не следует: логгер плохо переносит большое количество частых мелких запросов. Я читаю два непрерывных блока и раскладываю значения локально.

Первый запуск вернул правдоподобную картину: SOC батареи, напряжение, текущую нагрузку, импорт из сети и заряд. Дополнительно проверили энергетический баланс: импорт из сети примерно соответствует нагрузке, заряду батареи и собственному потреблению инвертора.

Карта основных регистров

У однофазных гибридных Deye серий SG03LP1, SG04LP1 и SG05LP1 используется близкая карта регистров. Для моего инвертора понадобились следующие поля:

Регистр Значение Масштаб
59 Статус инвертора 0 standby, 2 normal, 4 fault
109–112 Напряжение и ток PV1/PV2 ×0,1
186, 187 Мощность PV1/PV2 W
184 SOC батареи %
183 Напряжение батареи ×0,01 V
190 Мощность батареи W, signed
182 Температура батареи (x − 1000) × 0,1 °C
150 / 79 Напряжение / частота сети ×0,1 V / ×0,01 Hz
169 Мощность сети W, signed
178 Мощность нагрузки W
108, 70, 71, 76, 77, 84 Суточные счётчики энергии ×0,1 kWh

Часть значений знаковая: положительная мощность батареи означает разряд, отрицательная — заряд; положительная мощность сети — импорт, отрицательная — экспорт. Значения из uint16 необходимо корректно преобразовать в int16. Температуры хранятся со смещением 1000.

Архитектура: логгер → NAS → сервер

Wi-Fi-логгер

Локальный порт 8899, Solarman V5 и Modbus. Сборщик только читает регистры.

Synology NAS

Python-скрипт запускается планировщиком DSM раз в минуту и формирует снимок состояния.

Локальный буфер

Если сервер недоступен, замеры сохраняются в spool-файл на срок до 14 суток.

Laravel-сервер

API принимает данные, хранит историю, агрегирует старые записи и строит графики в админке.

Главное архитектурное решение: NAS сам отправляет данные наружу, а не публичный сервер подключается к домашней сети. Не нужны открытые порты, DDNS или публикация NAS в интернете. Домашняя сеть выполняет только исходящие HTTPS-запросы.

Как запустить сборщик на старом Synology

В проекте использован DS415play: 32-битный процессор, около 700 МБ RAM и DSM 7.1. Docker на эту модель не устанавливается, поэтому тяжёлый стек из Home Assistant, Grafana и InfluxDB не подходил.

Оказалось достаточно штатных возможностей:

Python 3.8 без pip

Библиотеки pysolarmanv5 и umodbus написаны на чистом Python. Wheel-пакеты можно распаковать в каталог vendor/ рядом со скриптом, ничего не устанавливая в систему.

Стандартная библиотека

HTTP-отправка реализована через urllib, блокировка — через fcntl, локальный буфер — в формате JSON Lines.

Планировщик DSM

Пользовательская задача запускает скрипт раз в минуту от непривилегированного сервисного пользователя.

Скрипт хранится в домашнем каталоге сервисного пользователя, а не в web-папке, поскольку в конфигурации находится API-ключ.

Надёжность круглосуточного сборщика

Скрипт должен не только правильно разбирать регистры, но и переживать реальные сбои.

  1. Логгер не ответил

    Wi-Fi-устройства иногда возвращают таймаут или повреждённый кадр. Сборщик делает одну повторную попытку через три секунды и записывает ошибку в лог.

    timeout · retry
  2. Сервер недоступен

    Замер попадает в spool.jsonl. При следующем успешном запуске накопленные данные отправляются пачками до 500 записей. Буфер ограничен двумя неделями.

    offline buffer · JSONL
  3. Пачка отправлена повторно

    Сервер выполняет upsert по уникальному ключу из серийного номера устройства и времени замера. Повторная доставка не создаёт дублей.

    idempotency · upsert
  4. Запуски наложились

    Если предыдущий цикл ещё работает, следующий не запускается. Для этого используется flock на lock-файле.

    flock · single process
  5. Лог растёт

    Файл автоматически ротируется после достижения 1 МБ.

    log rotation

Рабочий сборщик для Synology

Диагностический скрипт показывает данные в консоли, но для постоянной работы нужен сборщик. Ниже ключевые части моего deye_collector.py. Конфигурация с IP, серийным номером, endpoint и API-ключом хранится отдельно в config.json и в код не попадает.

Показать код чтения и подготовки замера
import datetime
from pysolarmanv5 import PySolarmanV5

STATES = {
    0: "standby", 1: "self-check", 2: "normal",
    3: "alarm", 4: "fault",
}


def s16(v):
    return v - 0x10000 if v >= 0x8000 else v


def read_inverter(cfg):
    inv = PySolarmanV5(
        cfg["logger_ip"],
        int(cfg["logger_sn"]),
        port=int(cfg.get("logger_port", 8899)),
        mb_slave_id=1,
        socket_timeout=10,
    )
    try:
        a = inv.read_holding_registers(59, 42)
        b = inv.read_holding_registers(108, 84)
    finally:
        inv.disconnect()

    def r(n):
        return a[n - 59] if n < 108 else b[n - 108]

    pv1_power, pv2_power = r(186), r(187)
    return {
        "measured_at": datetime.datetime.now(
            datetime.timezone.utc
        ).isoformat(timespec="seconds"),
        "device_sn": str(cfg["logger_sn"]),
        "device_name": cfg.get("device_name", "SUN-06K-SG05LP1-EU"),
        "status": STATES.get(r(59), str(r(59))),
        "pv1_voltage": round(r(109) * 0.1, 1),
        "pv1_current": round(r(110) * 0.1, 1),
        "pv1_power": pv1_power,
        "pv2_voltage": round(r(111) * 0.1, 1),
        "pv2_current": round(r(112) * 0.1, 1),
        "pv2_power": pv2_power,
        "pv_power": pv1_power + pv2_power,
        "battery_soc": r(184),
        "battery_voltage": round(r(183) * 0.01, 2),
        "battery_current": round(s16(r(191)) * 0.01, 2),
        "battery_power": s16(r(190)),
        "battery_temp": round((r(182) - 1000) * 0.1, 1),
        "grid_voltage": round(r(150) * 0.1, 1),
        "grid_frequency": round(r(79) * 0.01, 2),
        "grid_power": s16(r(169)),
        "load_power": r(178),
        "inverter_power": s16(r(175)),
        "temp_dc": round((r(90) - 1000) * 0.1, 1),
        "temp_ac": round((r(91) - 1000) * 0.1, 1),
        "day_pv_kwh": round(r(108) * 0.1, 1),
        "day_battery_charge_kwh": round(r(70) * 0.1, 1),
        "day_battery_discharge_kwh": round(r(71) * 0.1, 1),
        "day_grid_import_kwh": round(r(76) * 0.1, 1),
        "day_grid_export_kwh": round(r(77) * 0.1, 1),
        "day_load_kwh": round(r(84) * 0.1, 1),
    }
Показать код spool и пакетной отправки
import json
import os
import urllib.request

SPOOL_PATH = "spool.jsonl"
SPOOL_MAX_LINES = 20160  # около 14 суток
BATCH_MAX = 500


def load_spool():
    if not os.path.exists(SPOOL_PATH):
        return []
    out = []
    with open(SPOOL_PATH) as f:
        for line in f:
            try:
                out.append(json.loads(line))
            except ValueError:
                pass
    return out


def save_spool(items):
    items = items[-SPOOL_MAX_LINES:]
    tmp = SPOOL_PATH + ".tmp"
    with open(tmp, "w") as f:
        for item in items:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")
    os.replace(tmp, SPOOL_PATH)


def send(cfg, readings):
    body = json.dumps({"readings": readings}).encode()
    request = urllib.request.Request(
        cfg["endpoint_url"],
        data=body,
        method="POST",
        headers={
            "Content-Type": "application/json",
            "Accept": "application/json",
            "Authorization": "Bearer " + cfg["api_key"],
            "User-Agent": "deye-collector/1.0",
        },
    )
    with urllib.request.urlopen(request, timeout=20) as response:
        if response.status >= 300:
            raise RuntimeError("HTTP %s" % response.status)


def flush(cfg, pending):
    sent = 0
    while sent < len(pending):
        batch = pending[sent:sent + BATCH_MAX]
        send(cfg, batch)
        sent += len(batch)
    save_spool(pending[sent:])
Показать защиту от наложения запусков и retry
import fcntl
import time


lock = open(".lock", "w")
try:
    fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
    log("previous run still active, skip")
    raise SystemExit(0)

reading = None
for attempt in range(2):
    try:
        reading = read_inverter(cfg)
        break
    except Exception as error:
        log("read error (attempt %d): %r" % (attempt + 1, error))
        time.sleep(3)

pending = load_spool()
if reading:
    pending.append(reading)

try:
    flush(cfg, pending)
except Exception as error:
    log("send error: %r" % error)
    save_spool(pending)

В полной версии также есть ротация лога и безопасная обработка HTTP-ошибок. Важный принцип: при любой временной проблеме измерение остаётся локально, а сервер принимает повторную доставку идемпотентно.

Что происходит на Laravel-сервере

Модуль Deye изолирован от остальной логики приложения и не связан с AI/RAG-конвейером.

  • Приём данных: POST /api/deye/readings с отдельным Bearer-ключом. Ключ NAS даёт доступ только к этому endpoint.
  • Идемпотентное хранение: повторные замеры обновляются по паре «устройство + время», а не создают дубли.
  • Хранение: около 1440 минутных записей в сутки. Сырые данные старше 90 дней сворачиваются в почасовую агрегацию.
  • Read-only API: последний замер и история с группировкой по 5 минут, 15 минут, часу или дню.
  • Админка: карточки текущего состояния и графики мощностей, SOC, напряжения сети и суточной энергии за 24 часа, 7 или 30 дней.

Самым полезным неожиданно оказался график напряжения сети. На минутных данных отключения видны как провалы с точностью до минуты: понятно, когда исчезла сеть, сколько длилось отключение и насколько разрядилась батарея.

Безопасность локального Modbus

На порту 8899 нет авторизации. Устройство в той же сети потенциально может не только читать, но и записывать регистры, меняя режимы работы и параметры заряда. Поэтому:

  • по возможности поместите логгер и другие IoT-устройства в отдельную гостевую сеть или VLAN;
  • разрешите доступ к логгеру только со стороны сборщика;
  • никогда не пробрасывайте порт 8899 в интернет;
  • используйте только чтение регистров, если запись не является осознанной частью проекта;
  • закрепите за логгером постоянный IP через DHCP reservation.

На некоторых свежих прошивках локальный порт может быть закрыт или работать нестабильно. Запасной вариант — прямое подключение к RS485 через адаптер, например ESP32 с ESPHome. Это требует дополнительного оборудования, но может оказаться надёжнее Wi-Fi-логгера.

Результат

Я не менял прошивку, не покупал дополнительное оборудование и не отказывался от облака Deye. К уже установленному логгеру добавился второй локальный клиент. В результате я получил:

  • актуальные данные раз в минуту вместо облачной задержки;
  • собственную историю измерений;
  • API для виджетов и внешних сервисов;
  • графики состояния инвертора и электросети;
  • основу для Telegram-уведомлений об отключении сети и других автоматизаций;
  • работу на старом NAS, который уже был включён круглосуточно.

Обсудить задачу

Если у вас есть проект, связанный с Python, Laravel, Node.js, CRM, Telegram, AI/RAG, API-интеграциями, автоматизацией или TON/GRAM-логикой — напишите в свободной форме, что нужно сделать.

Можно описать задачу коротко: что есть сейчас, что не работает, какой результат нужен и какие сервисы уже используются.


Давайте обсудим проект

Расскажите, что хотите сделать. Я отвечу на вашу почту.

Или напишите в Telegram @ifwcom