A Network UPS Tools (NUT) driver that lets
a Sigenergy SigenStor hybrid inverter/battery system be monitored as a UPS
over its Modbus TCP interface — battery.charge, ups.status
(OL/OB/LB/CHRG/DISCHRG), input.voltage, input.frequency,
ups.realpower, and device identity, straight from the plant and inverter
Modbus registers.
Status: not upstream yet. nut-driver/sigenergy_modbus.c
is written against NUT's own driver API and coding standards, aimed at
eventual inclusion in networkupstools/nut
directly. This repo is that driver's actual source of truth, not a
downstream copy of some fork or PR branch — nut-driver/ is developed
and versioned here, and periodically exported to a fork
(miklosbagi/nut) purely as a staging area for proposing it upstream.
That's a deliberate ordering, not just convenience: there's no guarantee
upstream accepts a new driver, and this way sigennut keeps working
exactly the same regardless of whether that PR is accepted, rejected, or
never resolved — worst case, this becomes a permanently-maintained
"NUT plus this one driver" image, not something that stops working if
upstream says no. This image builds NUT from source (upstream master,
by default) and vendors this driver into it via
nut-driver/sigenergy_modbus-makefile.patch
— if/once the driver is merged upstream, that patch step (but not the
driver itself) becomes unnecessary for anyone running a NUT version
recent enough to include it.
Read-only by design: this driver contains no modbus_write_* call
anywhere. It won't reconfigure your inverter, schedule charging, or touch
EPS settings.
One known open item: on_off_grid_status (register 30009) is used as the
OL/OB signal. It's documented by Sigenergy's own Modbus spec as the
correct signal for exactly this purpose, but hasn't yet been field-verified
against a real grid-loss/grid-return transition. Treat OL/OB as
spec-correct-by-design but not yet field-proven.
docker compose up -d --build
upsc sigen@localhostThis builds sigennut from this checkout's local source
(docker/Dockerfile) and runs it alongside
InvForge's own published
:latest image, pre-loaded with real captured SigenStor register data —
so you can see it working with zero hardware, and testing local driver
changes doesn't need InvForge rebuilt too. See docker-compose.yml.
For everyone else, examples/ has ready-to-run compose files using the
published miklosbagi/sigennut and miklosbagi/invforge images
directly — copy either one down and run it, no git clone required.
Against InvForge (zero hardware) —
examples/docker-compose.emulator.yml:
docker compose -f docker-compose.emulator.yml up -d
upsc sigen@localhostAgainst your own SigenStor —
examples/docker-compose.real-device.yml:
SIGENSTOR_HOST=192.168.1.50 docker compose -f docker-compose.real-device.yml up -d
upsc sigen@localhostSIGENSTOR_HOST is your device's Modbus TCP host or host:port
(default port 502 if omitted) — compose refuses to start if it's unset,
rather than silently generating a broken ups.conf.
Both examples generate /etc/nut's config entirely inline, via a
one-shot init container writing into a named volume — not compose's own
configs: mechanism, which mounts individual files rather than the
directory itself and so doesn't satisfy startup.sh's check that
/etc/nut is a real mounted volume (confirmed the hard way). Same
permissions gpdm/nut-upsd's own convention requires either way: 0440,
owned by uid 100 / gid 101 (see docker/files/startup.sh).
Just InvForge, standalone (no sigennut/NUT at all — e.g. testing a different Modbus client against it):
docker run -d --name invforge \
-p 5020:502 -p 8080:8080 \
miklosbagi/invforge:latest \
--vendor sigenergy --firmware V100R001C21SPC116 \
--scenario 2026-08-14-idle-full-socSee InvForge's own README for its full scenario library and HTTP control API.
docker/Dockerfile is a two-stage build:
- builder — shallow-clones upstream NUT (
ARG NUT_REF, defaultmaster), copies innut-driver/sigenergy_modbus.c, appliesnut-driver/sigenergy_modbus-makefile.patch(fails the build loudly if upstream'sdrivers/Makefile.amhas drifted too far for the patch to apply — better than silently shipping an image without the driver wired in), then configures and compiles just this one driver with the same--sysconfdir=/etc/nut --with-statepath=/var/run/nut --with-user=nut --with-group=nutflags Alpine's ownnutpackage uses, so the compiled binary's paths agree with the rest of the image. - runtime — apk-installs Alpine's stock
nutpackage (this givesupsd,upsdrvctl, thenutuser/group, and every other stock driver for free — no need to reinvent that packaging), then splices the freshly compiledsigenergy_modbusbinary in over it.
Same operational conventions as
gpdm/nut-upsd, which
this is modeled on: config is 100% file-based via a required /etc/nut
volume mount, docker/files/startup.sh (copied near-verbatim, BSD license
notice intact) refuses to start unless that volume is mounted and every
config file is exactly mode 0440 owned by nut:nut, then runs
upsdrvctl start followed by exec upsd -D.
.github/workflows/ci.yml— builds the image, shellchecksstartup.sh(error severity only — see the file's own comment for why), and a smoke test confirming the whole stack comes up and reports data. A separate job re-runs the same smoke test against whatever's currently published on Docker Hub, since that validates the README's own quickstart, not this PR's code.tests/e2e/— scenario-level assertions on the actual NUT semantics: buildssigennutfrom this checkout's source, runs it against InvForge's published:latest, and checksupsc's real output across battery states (OL,OB DISCHRG,LB,CHRG, andOB DISCHRG LBcombined) — two from real captured device data, two from direct register overrides via InvForge's/stateendpoint (deterministic, no ramp-timing dependency). Run locally:docker compose -f tests/e2e/docker-compose.yml up -d --build pip install -r tests/e2e/requirements.txt pytest tests/e2e -v docker compose -f tests/e2e/docker-compose.yml down -v
GPL-2.0-or-later — see LICENSE. Same as
NUT itself and
nut-driver/sigenergy_modbus.c's own
header, since this image redistributes both.
docker/files/startup.sh is copied from
gpdm/nut-upsd under
its own BSD Simplified License (notice retained in the file).