Данные инвертора Deye раз в минуту: локальный мониторинг без облака
Практика · Deye · Solarman V5 · Modbus · Synology · Laravel
Приложение Deye показывает данные с заметной задержкой. Я научился читать гибридный инвертор SUN-06K-SG05LP1-EU напрямую через штатный Wi-Fi-логгер Solarman, собирать телеметрию раз в минуту на Synology и отправлять её на свой Laravel-сервер с историей, API и графиками.
Коротко: 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-ключ.
Надёжность круглосуточного сборщика
Скрипт должен не только правильно разбирать регистры, но и переживать реальные сбои.
-
Логгер не ответил
Wi-Fi-устройства иногда возвращают таймаут или повреждённый кадр. Сборщик делает одну повторную попытку через три секунды и записывает ошибку в лог.
timeout · retry -
Сервер недоступен
Замер попадает в
offline buffer · JSONLspool.jsonl. При следующем успешном запуске накопленные данные отправляются пачками до 500 записей. Буфер ограничен двумя неделями. -
Пачка отправлена повторно
Сервер выполняет upsert по уникальному ключу из серийного номера устройства и времени замера. Повторная доставка не создаёт дублей.
idempotency · upsert -
Запуски наложились
Если предыдущий цикл ещё работает, следующий не запускается. Для этого используется
flock · single processflockна lock-файле. -
Лог растёт
Файл автоматически ротируется после достижения 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-логикой — напишите в свободной форме, что нужно сделать.
Можно описать задачу коротко: что есть сейчас, что не работает, какой результат нужен и какие сервисы уже используются.
