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.

1 mincollection interval instead of cloud delay
0additional hardware
2 requestscomplete inverter snapshot
~15 linesof Python for the first test

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.

  1. 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
  2. The server is unavailable

    The reading is appended to spool.jsonl. After connectivity returns, pending data is sent in batches of up to 500 records. The buffer is limited to two weeks.

    offline buffer · JSONL
  3. 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
  4. Runs overlap

    If the previous cycle is still active, the new one exits immediately. A lock file and flock enforce this rule.

    flock · single process
  5. 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 ingestionPOST /api/deye/readings uses 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.


Let’s discuss your project

Tell me what you would like to build. I will reply by email.

Or message me on Telegram @ifwcom