Deye Inverter Data Every Minute: Local Monitoring Without the Cloud
Hands-on · Deye · Solarman V5 · Modbus · Synology · Laravel
The Deye app displays inverter data with a noticeable delay. I learned how to read a SUN-06K-SG05LP1-EU hybrid inverter directly through its standard Solarman Wi-Fi logger, collect telemetry every minute on a Synology NAS, and send it to my Laravel server for history, APIs, and charts.
In short: the Solarman Wi-Fi logger used with Deye listens on local TCP port 8899. It can expose inverter registers directly over Modbus. A collector on my home Synology reads the inverter every minute and sends the measurements to my server.
The problem with cloud monitoring
This project uses a single-phase Deye SUN-06K-SG05LP1-EU 6 kW hybrid inverter with a battery and a Solarman Wi-Fi logger. The standard data path looks like this:
Inverter → Wi-Fi logger → Solarman/Deye cloud → mobile app
The logger uploads data to the cloud in batches every few minutes. The data then has to be processed and displayed by the app. As a result, the screen may show a state that is five minutes old or even older. That is acceptable for checking daily production, but not for real-time monitoring or automation.
Fast events matter for a hybrid inverter:
- the grid goes down and the house switches to battery power;
- the battery discharges faster than expected;
- a peak load appears;
- the grid returns and battery charging begins.
The history also remains in a third-party cloud. Without a private API, it is difficult to build custom charts, combine the telemetry with other systems, or send Telegram alerts.

What the standard Wi-Fi logger can do
A Solarman LSW-3/LSW-5 is a small bridge between Wi-Fi and RS485. Internally, it talks to the inverter over Modbus RTU. It uploads data to the cloud, but it also listens on TCP port 8899 inside the local network.
This port uses the Solarman V5 protocol: a Modbus frame wrapped in a header containing the logger serial number. Python already has a suitable library, pysolarmanv5. The connection requires:
- the logger's local IP address, available in the router's client list;
- the logger serial number, not the inverter serial number;
- port 8899, which can be tested with
nc -z <ip> 8899.
The Deye cloud and app continue to work normally. I do not flash or disable anything; I simply add a second local client.
First test: a small Python script
Before building the complete pipeline, I verified that the logger responded and that the register map matched the inverter. This is a sanitized version of the working deye_read.py; replace the local IP and serial number with your own values.
Show the deye_read.py diagnostic script
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) # registers 59..100
b = inv.read_holding_registers(108, 84) # registers 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"Status: {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 today: {r(108)*0.1:.1f} kWh")
print(f"SOC: {r(184)} %")
print(f"Battery: {r(183)*0.01:.2f} V, {s16(r(190))} W")
print(f"Grid: {r(150)*0.1:.1f} V, {r(79)*0.01:.2f} Hz")
print(f"Import/export: {s16(r(169))} W")
print(f"Load: {r(178)} W")
print(f"DC/AC temp: {(r(90)-1000)*0.1:.1f} / {(r(91)-1000)*0.1:.1f} °C")
if __name__ == "__main__":
sys.exit(main())
The complete inverter state fits into two requests. Reading each register separately is a bad idea because the logger does not handle many small, frequent requests well. I read two continuous blocks and decode the values locally.
The first run returned a plausible snapshot: battery SOC and voltage, current load, grid import, and charging power. I also checked the energy balance: grid import was approximately equal to the load, battery charging, and the inverter's own consumption.
Map of the main registers
Single-phase Deye hybrid inverters in the SG03LP1, SG04LP1, and SG05LP1 families use similar register maps. I needed the following fields for my inverter:
| Register | Value | Scale |
|---|---|---|
| 59 | Inverter status | 0 standby, 2 normal, 4 fault |
| 109–112 | PV1/PV2 voltage and current | ×0.1 |
| 186, 187 | PV1/PV2 power | W |
| 184 | Battery SOC | % |
| 183 | Battery voltage | ×0.01 V |
| 190 | Battery power | W, signed |
| 182 | Battery temperature | (x − 1000) × 0.1 °C |
| 150 / 79 | Grid voltage / frequency | ×0.1 V / ×0.01 Hz |
| 169 | Grid power | W, signed |
| 178 | Load power | W |
| 108, 70, 71, 76, 77, 84 | Daily energy counters | ×0.1 kWh |
Some values are signed: positive battery power means discharge, negative means charging; positive grid power means import, negative means export. Values must be converted correctly from uint16 to int16. Temperatures are stored with an offset of 1000.
Architecture: logger → NAS → server
Wi-Fi logger
Local port 8899, Solarman V5, and Modbus. The collector only reads registers.
Synology NAS
A Python script runs every minute through DSM Task Scheduler and creates a state snapshot.
Local buffer
If the server is unavailable, measurements remain in a spool file for up to 14 days.
Laravel server
The API receives telemetry, stores history, aggregates old records, and provides admin charts.
The key architectural decision is that the NAS sends data out; the public server never connects back into the home network. There is no need to expose ports, configure DDNS, or publish the NAS on the internet. The home network only makes outbound HTTPS requests.
Running the collector on an old Synology
The project uses a DS415play with a 32-bit processor, roughly 700 MB of RAM, and DSM 7.1. Docker is not available for this model, so a container stack with Home Assistant, Grafana, and InfluxDB was not an option.
The standard system features were enough:
Python 3.8 without pip
pysolarmanv5 and umodbus are pure-Python packages. Their wheel archives can be unpacked into a vendor/ directory next to the script without installing anything system-wide.
Python standard library
HTTP delivery uses urllib, locking uses fcntl, and the local buffer uses JSON Lines.
DSM Task Scheduler
A user-defined task runs the script every minute under an unprivileged service account.
The script lives in the service user's home directory rather than a web directory because its configuration contains an API key.
Making a 24/7 collector reliable
A collector must survive real failures, not merely decode registers correctly.
-
The logger does not respond
Wi-Fi loggers occasionally time out or return a malformed frame. The collector makes one retry after three seconds and writes the error to its log.
timeout · retry -
The server is unavailable
The reading is appended to
offline buffer · JSONLspool.jsonl. After connectivity returns, pending data is sent in batches of up to 500 records. The buffer is limited to two weeks. -
A batch is delivered twice
The server performs an upsert using the device serial number and measurement time as a unique key. Repeated delivery does not create duplicates.
idempotency · upsert -
Runs overlap
If the previous cycle is still active, the new one exits immediately. A lock file and
flock · single processflockenforce this rule. -
The log keeps growing
The log file rotates after reaching 1 MB.
log rotation
The working Synology collector
The diagnostic script prints values to the console, but continuous operation requires a collector. The following excerpts come from my working deye_collector.py. The IP address, serial number, endpoint, and API key are kept separately in config.json.
Show inverter reading and snapshot preparation
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),
}
Show the spool and batched delivery code
import json
import os
import urllib.request
SPOOL_PATH = "spool.jsonl"
SPOOL_MAX_LINES = 20160 # approximately 14 days
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:])
Show overlap protection and retry logic
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)
The complete version also rotates logs and handles HTTP errors safely. The essential rule is that every reading remains local during a temporary failure, while the server accepts repeated delivery idempotently.
What happens on the Laravel server
The Deye module is isolated from the rest of the application and is not connected to the AI/RAG pipeline.
- Data ingestion
POST /api/deye/readingsuses a dedicated Bearer key. The NAS key can access only this endpoint. - Idempotent storageRepeated measurements update the row identified by device and timestamp instead of creating duplicates.
- RetentionThe system receives about 1,440 minute-level records per day. Raw data older than 90 days is aggregated into hourly rows.
- Read-only APIClients can request the latest reading or history grouped by 5 minutes, 15 minutes, one hour, or one day.
- Admin dashboardCards and charts show power, SOC, grid voltage, and daily energy over 24 hours, 7 days, or 30 days.
The grid voltage chart turned out to be especially useful. Minute-level data shows outages as visible drops: I can see when the grid disappeared, how long the outage lasted, and how much battery capacity was used.
Securing local Modbus access
Port 8899 has no authentication. A device on the same network may be able not only to read registers but also to write them, changing operating modes and charging parameters. Therefore:
- place the logger and other IoT devices in a separate guest network or VLAN where possible;
- allow access to the logger only from the collector;
- never expose port 8899 to the internet;
- use register reads only unless writing is a deliberate and validated part of the project;
- assign the logger a stable IP through DHCP reservation.
Some recent logger firmware versions close the local port or make it unstable. A fallback option is a direct RS485 connection through an adapter such as an ESP32 running ESPHome. It requires additional hardware but can be more reliable than the Wi-Fi logger.
Result
I did not change the firmware, buy additional equipment, or abandon the Deye cloud. I simply added a second local client to the existing logger. The result is:
- fresh data every minute instead of delayed cloud updates;
- a privately owned measurement history;
- an API for widgets and external services;
- charts for inverter and grid state;
- a foundation for Telegram outage alerts and other automations;
- a solution that runs on an old NAS that was already online around the clock.
Discuss your project
If you have a project involving Python, Laravel, Node.js, CRM, Telegram, AI/RAG, API integrations, automation or TON/GRAM logic, send me a short description of what you need.
You can simply explain what exists now, what is not working, what outcome you expect and which services are already involved.
