Skip to content

Releases: ch3p4ll3/sitsolar-dongle

v0.2.1

Choose a tag to compare

@ch3p4ll3 ch3p4ll3 released this 19 Oct 20:36
17186e9

Project rename

The project is renamed from sitsolar-dongle-reader to sitsolar-dongle.

Nothing changes for users. The PyPI distribution name, the import package
and the console script were already sitsolar-dongle, and all three are
untouched — pip install sitsolar-dongle, import sitsolar_dongle and the
sitsolar-dongle command work exactly as before, with no migration and no
change to the API.

Changed

  • Repository ch3p4ll3/sitsolar-dongle-reader →
    ch3p4ll3/sitsolar-dongle.
    GitHub redirects the old address, so existing links keep working.
  • Documentation site →
    ch3p4ll3.github.io/sitsolar-dongle.
  • Attribution now names ch3p4ll3 as the author rather than
    "sitsolar-dongle-reader contributors".
  • The background reader task is named sitsolar-dongle in place of
    sitsolar-dongle-reader.

v0.2.0

Choose a tag to compare

@ch3p4ll3 ch3p4ll3 released this 18 Oct 22:10
719c779

Asyncio support, and a session that survives a long-lived connection.

This release adds AsyncDongleClient, a non-blocking counterpart to DongleClient for Home Assistant, FastAPI and other asyncio applications. It shares the same protocol core, register map and error types as the blocking client, so the returned values and exceptions are identical.

DongleClient is unchanged in behaviour and remains the right choice outside an event loop.

New: AsyncDongleClient

from sitsolar_dongle import AsyncDongleClient

async with AsyncDongleClient("192.168.1.50", "<DONGLE_SERIAL>") as dongle:
    values = await dongle.poll()

Nothing in the module blocks the event loop: there is no socket, time.sleep or synchronous recv anywhere in it. Concurrent callers are serialised with an asyncio.Lock, since a Modbus request/reply pair cannot be interleaved on one connection.

Heartbeats are answered by a background reader

The dongle pushes a heartbeat roughly every 37 s and closes a connection whose heartbeats go unanswered. Measured on real hardware, a session that never answered was dropped after about five missed pushes (roughly three minutes), while an identical session that answered stayed up for ten minutes and polled normally throughout.

A background task therefore owns the connection and replies the instant a push arrives, exactly as the official app does. Your polling interval has no bearing on whether the session survives, and reconnecting per poll is unnecessary.

A failure costs much less than it used to

Under the previous design every read owned the socket, so an interrupted read tore the connection down. Against the dongle's ~75 s reconnect lockout, that turned a routine cancellation into a 75 s outage. Now:

  • Cancelling or timing out a request leaves the connection usable. The reader has already consumed whatever was on the wire, and a reply that arrives late is discarded by transaction id. This is the normal asyncio.timeout pattern in Home Assistant, and it no longer costs a reconnect.
  • A dead connection is noticed as it happens, not at the next poll. That covers a closed socket, an RST, keepalive seconds of silence, or three consecutive request timeouts. Any request waiting on it fails immediately instead of burning its full timeout.
  • A connection that is alive but stops answering is reclaimed. connected alone cannot see that failure, because a dongle can finish the handshake and then ignore every read. Three timeouts in a row now drop it. A successful reply resets the count, so a busy inverter is not mistaken for a broken one.
  • connected is trustworthy. It goes False exactly when the connection is finished, which is what a reconnect backoff should key off.
  • close() is bounded, with a hard abort as fallback, so an unload handler cannot hang on a dead link.

One exception covers everything

DongleError is now the single base exception for every failure: refused connection, refused accreditation, bad HMAC, Modbus exception, dropped connection or timeout. One except is enough.

It has moved to sitsolar_dongle.errors, but is still exported from the top level and from sitsolar_dongle.client, so existing imports keep working. This release introduces no breaking changes.

Also included

  • py.typed: the package now declares its inline annotations under PEP 561, so type checkers use them.
  • A Home Assistant integration example with reconnect backoff, wired to a DataUpdateCoordinator. It is extracted from the documentation and executed by the test suite, so it cannot silently drift.
  • A measured-behaviour section in the docs recording what was actually tested on hardware and, just as usefully, what was not, including the open questions.

For contributors

  • Tests go from 22 to 61, including a scripted fake dongle that derives the same keys and speaks the real protocol end to end.
  • tools/hw_probe.py contains the hardware experiments behind the claims above. It is a diagnostic, ships in neither the sdist nor the test suite, and takes the serial via --sn-file so it stays out of your shell history.

v0.1.0

Choose a tag to compare

@ch3p4ll3 ch3p4ll3 released this 17 Oct 18:17

sitsolar-dongle 0.1.0

First release.

A read-only Modbus client for the SiNeng / FuturaPulse WiFi dongle that ships
with Sit Solar inverters — the "Si-Dongle-L" listening on TCP port 7078. It
implements the same secure Modbus frame the official Android app speaks, so you
can pull inverter telemetry straight off your LAN instead of routing it through
the vendor cloud.

Unofficial project. Not affiliated with, endorsed by, or supported by
SiNeng, FuturaPulse or Sit Solar. The protocol was reverse-engineered from the
vendor's Android app and its published Modbus protocol document. Use at your
own risk, on your own equipment.


Highlights

  • Read-only by design — only Modbus FC 0x04 and 0x03 reads. The writable
    holding-register block 40001–49999 is deliberately absent from the map.
  • Full register map — 183 decoded values across 12 sections, transcribed from
    the FuturaPulse hybrid inverter Modbus protocol v0.0.3, with enums, bit
    fields, scale factors, and documented hardware quirks.
  • Authenticated transport — every frame is AES-128-CBC encrypted and
    HMAC-SHA256 signed, with keys derived from the dongle serial.
  • Tested against real hardware — decoded lifetime totals cross-checked
    against the vendor's own cloud API.

Features

Command line

# one-shot, human readable
sitsolar-dongle 192.168.1.50 --sn <DONGLE_SERIAL>

# machine readable
sitsolar-dongle 10.0.0.5 --sn ... --json

# follow forever, one sample every 15 s (Home Assistant / MQTT)
sitsolar-dongle 10.0.0.5 --sn ... --interval 15 --json

# filter by section or by key
sitsolar-dongle 10.0.0.5 --sn ... --section "AC_side" --raw
sitsolar-dongle 10.0.0.5 --sn ... --key grid_frequency --key battery_soc

# raw reads, bypassing the register map
sitsolar-dongle 10.0.0.5 --sn ... --read 31002:2 --read 30100:1

# print the built-in register map
sitsolar-dongle --list

Options cover --port, --slave, --fc {04,03}, --timeout, --errors {skip,raise}, --include-device-info, and --version. Exit codes: 0 success,
2 connection/protocol failure, 3 Modbus-level failure, 130 interrupted.

Library

from sitsolar_dongle import DongleClient

with DongleClient("192.168.1.50", "<DONGLE_SERIAL>") as dongle:
    print(dongle.info)                 # factory, model, serial, clock
    print(dongle.read(31002, 2))       # two 16-bit words, big endian
    for key, reading in dongle.poll().items():
        if "value" in reading:
            print(key, reading["value"], reading.get("unit", ""))

poll() reads every documented read-only register and returns decoded values.
Ranges a given firmware refuses are recorded under _unreadable_ranges rather
than raising — pass on_error="raise" to make them fatal.

Lower-level building blocks (derive_keys, seal, open_envelope, build_read,
parse_read_reply, crc16, decode) are exported too.

Protocol

  • 7-byte clear header (tid, protocol, length, unit) + AES-128-CBC
    envelope: IV(16) | ciphertext | HMAC-SHA256(32).
  • HMAC verified before decryption, so a wrong serial fails cleanly with
    HMAC mismatch (wrong serial number?) instead of producing garbage.
  • Vendor-specific CRC16 (not the standard Modbus CRC), big-endian on the wire,
    guarded by an import-time self-test.
  • Full handshake: BeforeAccredit → Accredit → CallAll → FC 0x04 reads.
  • Heartbeat pushes (43 01) answered automatically, mirroring the app.

Install

pip install sitsolar-dongle

Requires Python 3.10+ and pycryptodome. From source:

git clone https://github.com/ch3p4ll3/sitsolar-dongle-reader.git
cd sitsolar-dongle-reader
uv venv && uv sync

Documentation

Published at https://ch3p4ll3.github.io/sitsolar-dongle-reader/

10 pages covering Getting Started, the CLI reference, the Python API, the wire
protocol and crypto, the complete register map, caveats/troubleshooting, and
development. Built with Retype from docs/ and deployed
automatically to GitHub Pages.


Automation

Workflow Trigger What it does
ci.yml Push to main, PRs Tests on Python 3.10–3.14, builds sdist/wheel, validates metadata
docs.yml Push to main (docs) Builds Retype site, deploys to GitHub Pages
publish-pypi.yml GitHub Release published Tests → builds → version-gate → publishes to PyPI via Trusted Publishing

The distribution version is single-sourced from __version__
(src/sitsolar_dongle/__init__.py). Packaging uses PEP 639
(license = "GPL-3.0-or-later"), built with hatchling.


Known caveats

  • One connection at a time. The dongle stops accepting new TCP connections
    for ~75 s after a rapid reconnect.
  • Firmwares differ. Single-phase units have no S/T phase, and optional blocks
    (EPS, meter, Italy self-test) may refuse a read — recorded under
    _unreadable_ranges.
  • Where the document disagrees with the hardware is documented per register:
    31039~31040 tracks P rather than true apparent power, 31022 reads a fixed
    setpoint 0.999 rather than instantaneous PF, and 33002 likely uses a 0.01
    scale rather than the documented 0.1.

See Caveats & Troubleshooting
for the full list, including the two distinct kinds of refusal (dongle stub vs.
Modbus exception).


Security

Warning

The dongle serial number is a credential. Both session keys are a pure
function of the dongle serial:

aes_key  = MD5(sn || crc16_be(sn))
hmac_key = MD5(reverse(sn) || crc16_be(reverse(sn)))

Anyone on your network who knows that serial can open an authenticated
session with your dongle. Do not commit it, paste it into issues, or share
logs and packet captures containing it.


Tested with

Dongle SiNeng / FuturaPulse "Si-Dongle-L"
Inverter Futura Sun 3K-HS (single-phase high-voltage hybrid, device type 33)
Firmware DSP1 0000090021, DSP2 0000060010, ARM 001002032

Other models and firmware versions may expose different register blocks —
reports are welcome, please redact your serial number first.


Verification

  • 22 tests passing (CRC16 lookup tables pinned against vectors from the APK
    and a live dongle, framing, and decoding).
  • twine check --strict passes on both sdist and wheel.

License

GNU General Public License v3.0 — see LICENSE.

Full Changelog: d1eb8f0...9a69e88