Repository navigation
Opt-in writable NIBE driver: feed PV production to the Solar PV registers (2107/2108/2109), later cap the electric add-heat #537
Description
Follow-up to #530 (read-only NIBE local REST driver), #532 (heat-pump dashboard UI) and srcfl/hugin-drivers#7 (driver packaging). The shipped driver is observe-only by design — driver_command returns false and the header promises "NO control". This issue plans the opt-in write path.
Goal
Feed the pump's native Solar PV feature: periodically write live PV production into the Local REST API so the pump raises its heating / hot-water setpoints when there is surplus solar — the role NIBE's own Modbus accessory plays today, with forty-two-watts acting as that accessory.
This is control-by-hint, not direct actuation: the pump's own firmware decides what to do with the number. Misbehaviour degrades to "pump believes a wrong solar value", never to unsafe operation — which is what makes a writable v1 tractable.
Target points (seen in the S735 register map; writable flag to be confirmed in phase 0)
| Point | Title | Role |
|---|---|---|
| 2107 | Modbus TCP/IP Ext. (Solar PV) | master enable — "an external source reports PV over TCP/IP" |
| 2108 | Include own consumption (Solar PV) | semantic switch: does the written value already account for house load? |
| 2109 | Available power (Solar PV) | the periodically written power value |
| — | Offset heating/cooling/pool (Solar PV) | user-tunable aggressiveness — stays user-owned, we don't write it |
What these registers mean (semantics)
The S-series has a built-in "Solar PV" input designed for NIBE's Modbus TCP/IP accessory. When 2107 is enabled, the pump expects an external box to keep 2109 updated with the currently available solar power. 2108 tells the pump how to interpret that number:
- 2108 off → 2109 is gross PV production; the pump subtracts its own draw when judging surplus.
- 2108 on → 2109 is already net available power (production minus household consumption); the pump treats it as directly usable.
forty-two-watts knows both quantities from site telemetry (PV production + site meter), so either mode is implementable — the config picks one and the forwarder computes the matching value. When surplus exists, the pump applies the user-set Offset heating/cooling/pool setpoint shifts to soak it up.
Design sketch
Data flow — follows the loadpoint precedent (loadpoint.Controller → SenderFunc → registry.SendCommand → driver_command):
main dispatch cycle
└─ solar-feed forwarder (clamp + stale guard)
└─ SendCommand(nibe, {action:"solar_pv", power_w:…})
└─ nibe_local.lua driver_command
└─ PATCH /api/v1/devices/{id}/points {"2109": value}
- Sign convention: PV is negative site-convention W; conversion to the pump's positive-W value happens in the driver, as always (
docs/site-convention.md). - The forwarder is a small piece of the main loop, not a new planner: it forwards a telemetry-derived number at poll cadence. No schedule, no SoC — so it should not be a loadpoint; just a dedicated forwarder alongside the dispatch cycle.
Host API: add host.http_patch(url, body, headers) (or a generic http_request(method, …)), gated separately from read access — e.g. capabilities.http.allow_write: true. Without the grant, PATCH returns an error string exactly like ungranted MQTT/Modbus does today, so read-only remains the default even for drivers that already have http.
Opt-in — all three must hold before a single write happens:
capabilities.http.allow_write: trueinconfig.yaml(host-enforced)- driver-level
config.write.solar_pv: true - pump-side: 2107 enabled by the owner
Safety (per docs/clamping.md, every clamp answers a quantifiable risk):
- Clamp the written value to
[0, PV nameplate]— risk: a sign bug or telemetry spike telling the pump there are 100 kW of surplus. - Stale guard: if site-meter or PV telemetry is stale, write
0once, then stop — mirrors the existing stale-meter dispatch guard. Risk: pump chasing yesterday's sunshine. - Watchdog /
DefaultMode→ stop writing. Absence of writes must be the safe state; phase 0 confirms how the pump times out a silent feed. - Write cadence: only on change beyond a deadband, at most once per poll (60 s). Phase 0 confirms 2109 is RAM-backed, not EEPROM (flash wear).
Phases
Phase 0 — groundwork (no writes yet)
- Enumerate all
writable: truepoints from the live catalog (the metadata already carries the flag; one filtered bulk GET) - Confirm write mechanics from the pump's OpenAPI self-description at
https://<ip>:8443/— PATCH body shape, whether any pump-side mode must be enabled first, rate limits - Confirm what the pump does when writes stop (revert-to-no-solar timeout?) — this defines the failure mode
- Decide default semantics: gross production vs. net surplus (2108) — leaning net surplus, since 42W computes it better than the pump can guess
Phase 1 — Solar PV feed (core of this issue)
-
host.http_patch+http.allow_writecapability + a capability-denied test (pattern:TestLuaHTTPCapabilityNotGranted) - Solar-feed forwarder in the main loop with clamp + stale guard + deadband
-
driver_commandinnibe_local.luaimplementing thesolar_pvaction (and updating the "observe-only" header contract) - Config schema + docs (
configuration.md,safety.md, driver docs) - UI: show feed status on the heat-pump card — last written value + timestamp
Phase 2 — electric add-heat (immersion heater) power limit
- Identify the "max electrical addition" point id from the phase-0 writable catalog
- Wire it to the fuse guard / capacity-tariff logic (the per-phase fuse clamp already exists in loadpoint) so resistive heat can be capped when the mains fuse or the power tariff is under pressure
Phase 3 — parked ideas (split into their own issues when reached)
- Temporary lux (hot-water boost) to dump large surplus into the tank
- Blocking add-heat during price spikes — needs care: the pump's own "smart price adaption" may already be optimising on price, and two optimisers fighting is worse than one
Test site
Writes need a site where trial-and-error is acceptable: the S735 the read-only driver was verified on is the candidate (same pump, creds and cert pin already provisioned). Everything defaults off for everyone else.
Activity
HuggeK commentedon Jul 29, 2026
Written by Claude (Opus 4.8) on Hugo's behalf — phase-0 findings plus the first implementation slice.
Phase 0 of this issue is now answered, and the driver-side write path + host prerequisite are implemented as two draft PRs:
host.http_patch+capabilities.http.allow_write) → srcfl/ftw#716The driver is rebased onto current
device-driversmain. Editing it drops its byte-identity exemption frombaselines/ftw, so the full catalog convention suite now applies — it passes (includingtest_no_undefined_locals), andmake check's validators (manifest sync, host-API profile, history, baseline, sandbox) are green. The two PRs are independent: the driver detects a core withouthost.http_patchand stays read-only, so it is safe to land ahead of the core change.Phase-0 findings
Write mechanics — confirmed.
PATCH /api/v1/devices/{id}/pointswith a JSON array of datavalue objects:[{"type":"datavalue","variableId":N,"integerValue":<raw pre-divisor int>,"stringValue":""}]. ThevariableIdis the Local-API id, not the Modbus register — but every point's metadata carriesmodbusRegisterID, so the driver resolves 2107/2109 → variableIds from the bulk GET it already does, no hard-coded ids.The API's most dangerous habit: the pump rejects writes inside an HTTP 200. The per-point result string (
modified/error: read only value/error: no such param) is the real verdict. The driver parses it and surfaces a read-only pump as an actionable error naming the installer menu.Correction to our docs:
aidModeis not the write gate. Aid mode is the pump's compressor-off fault-recovery state. The actual write permission is the installer's read-only vs read/write choice for the Local REST API (menu 7.5.15). Our driver header anddocs/nibe-local.mdsaid "read-only mode (aidMode=off)" — both PRs fix that.What happens when writes stop — undocumented. No public source states how (or whether) the pump times out a silent 2109 feed. So the driver assumes the worst and owns the failure mode itself: a dead-man's switch writes 0 when commands stop (
write.ttl_s, default 300 s),driver_default_modeclears the feed on watchdog/stale-site-meter/stop, and a startup sweep clears a non-zero feed left by a crashed run. Confirming the pump's actual timeout stays on the HIL checklist for the S735.Writable-register risk classification (why 2107/2109 is the right first write, and what stays off-limits)
Registers are S-series Modbus ids (the public register export mirrors the pump's own menu 7.5.9 export; Local-API variableIds resolved at runtime).
DELETE /notifications·POST /aidmode· 2755 inverter fault reset · 137/139 defrost · 5217–5225 external sensor overridesNotably 5217–5225 let an external system replace physical sensor readings — a spoofed tank temp can stop DHW charging (legionella) and a spoofed outdoor temp corrupts the whole heat curve. The driver hard-codes nothing outside 2107/2109 and rejects every other action.
What the implementation enforces (per this issue's design sketch)
All three opt-ins required before a single write:
capabilities.http.allow_write: true(host-enforced — PATCH never leaves the core without it), driverwrite.solar_pv: truewith a mandatorywrite.max_wclamp ceiling (refuses to arm without it), and pump-side 2107 enabled by the owner (checked from the live register map each poll; refused with a pointed error while off). Value clamped to[0, max_w](and below the u16 sentinel), deadband (default 50 W) + at-most-one-write-per-interval, sign conversion (site-negative → pump-positive) inside the driver, and ahp_solar_pv_feed_wmetric so the feed is observable in the TS DB alongside the pump's own 2109 readback.Still open on this issue
HuggeK commentedon Jul 29, 2026
Status update — implemented, and verified as far as available hardware allows
Posted by Claude (Opus 4.8) on Hugo's behalf.
Both draft PRs are up and green on everything that can run without live solar hardware:
nibe_local1.1.0 → 1.2.0, the Solar PV surplus feed. Rebased onto device-driversmain; passes the full catalog convention suite (it lost itsbaselines/ftwbyte-identity exemption the moment it was edited).host.http_patchgated bycapabilities.http.allow_write; unit tests pin both gates, the wire format, and the redirect refusal.What the live system can and cannot confirm
Hugo's production gateway (FTW v1.13.4) runs the NIBE Local REST driver against a real S-series pump, and it is healthy:
status: ok, 0 consecutive errors, 14 358 successful ticks, last success seconds before this write, no device fault.So the read path is field-proven on real hardware. But that site has no PV and no battery (
pv_w: 0, PV today0 Wh), and v1.13.4 is read-only regardless — so the one thing that still needs a real device is precisely the new bit: an actual 2109 write, and how the pump behaves when the feed stops.No write was attempted against the live pump — the write path isn't deployed there, there's no surplus to feed, and it's someone's heating.
🙋 Looking for a tester: S-series heat pump + PV
This is the gap we can't close ourselves — Hugo has the pump but not the solar. If you run a NIBE S-series (S735/S1255/S1155/S320/…) alongside a PV array, we'd love your help validating the Solar PV feed on real hardware. Specifically:
It runs read-only until you opt in three times over (host
allow_write, driverwrite.solar_pv: true+write.max_w, and the pump-side 2107), so trying it is low-risk. Reply here or ping Hugo if you can help.feat(drivers): add host.http_patch write verb gated by capabilities.h…
HuggeK commentedon Aug 4, 2026
HIL results: the write path ran against a real S-series pump
The Solar PV write mechanism has now been validated end-to-end on real
hardware — an S-series pump on its Local REST API, on the same site that
produced the earlier read-path findings. The owner switched the pump's API to
read/write (installer menu 7.5.15) for this test. Every safety mechanism in
the driver fired as designed, on the first attempt.
What was proven, in order:
point (register 2109) came back
modified— judged in the response body,which is where this pump reports refusals. Menu 7.5.15 read/write is
therefore confirmed as the real pump-side gate.
capabilities.http.allow_writegate, the per-driver host allowlist, thepinned self-signed TLS cert, and the
host.http_patchverb from feat(drivers): host.http_patch write verb for device REST writes (#537) #716 —no step needed adjustment.
2109 back through the bulk points GET: the telemetry series shows
0 → 40 → 0, exactly bracketing the write and the later clear.write.ttl_s: 120and no furthercommands, the driver cleared the feed autonomously at +124 s
(
feed stale — dead-man's switch). Observed twice.container was SIGKILLed (no chance to clear) and started again. The fresh
driver's first poll found the stranded value and zeroed it:
clearing orphaned feed from a previous run.write_capabilities = { "solar_pv" }declaration surfaced through/api/drivers/catalog(source: local) and the Settings → Devices panelfrom feat(web): turn a driver's write path on from Settings #769 is served and renders the armed state.
Test setup and how the write was driven
host-mounted over the site's v1.15.0 compose install via a
docker-compose.override.ymlbind — the layout, data directory and therest of the stack untouched.
(
sha256 69a74a82…) as a local override in the user-drivers directory.driver_command("solar_pv", …)in a stock system. For the test only, thelocal override carried a config-gated one-shot self-test
(
write.self_test_w: 40) that pushed one value through the standardwrite_pv_surpluspath and armed the standard dead-man's switch. It wasremoved afterwards; the site now runs the clean fix: 5 Go-side P1 bugs from Codex review #46 artifact.
UI uses (
POST /api/config); the pump credentials never left the site.which makes any written value behaviorally inert — deliberately so for a
mechanism test. The driver's policy gate (nonzero command feeds require
2107 = 1) was not exercised for that reason.
One pump behavior worth recording: the S-series accepts writes to 2109
even while 2107 is off. That is convenient for commissioning (FTW can stage
and clear values without the owner flipping the master switch), but it also
means a stale value can sit invisibly until the owner enables 2107 — which is
exactly why the driver's orphan sweep and dead-man's switch matter, and both
held.
Still open (unchanged from the list above, minus what this run closed):
the core-side solar-feed forwarder in the dispatch loop — this test drove the
write with local instrumentation precisely because the forwarder doesn't
exist yet; a surplus-driven end-to-end run on a PV-equipped S-series site;
and the 2107 = 1 behavioral half (what the pump does with the fed value:
silent-feed timeout, 2108 gross-vs-net).
🤖 Generated with Claude Code
HuggeK commentedon Aug 5, 2026
The core-side forwarder is no longer missing — the one open item that made the HIL run need local instrumentation is now part of #769 (commit f127729). Enabling the Settings switch is end-to-end: every control tick, core computes the site's solar-attributable export —
min(live PV generation, grid export − battery/V2X discharge)— and sends it to the driver, which clamps, deadbands, rate-limits and writes register 2109. Stale site-meter telemetry stops the feed through the existing freshness gate, and the driver's default mode / dead-man switch clears the register.With that, this issue's phase 1 is complete across its three parts — driver write path (device-drivers#46),
host.http_patchcore verb (#716, merged), and now switch + forwarder (#769) — so this issue is wired to close automatically when #769 merges. Phase 2 (add-heat power cap) and phase 3 (hot-water boost via 697) should get their own issues when someone picks them up, so their scope doesn't ride on a closed tracker.🤖 Generated with Claude Code
feat(nibe_local): add opt-in Solar PV surplus write path (srcfl/ftw#537)
feat(nibe_local): add opt-in Solar PV surplus write path (srcfl/ftw#537)
Merge pull request #46 from HuggeK/nibe-solar-pv-feed