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: true in config.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
0 once, 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)
Phase 1 — Solar PV feed (core of this issue)
Phase 2 — electric add-heat (immersion heater) power limit
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.
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_commandreturnsfalseand 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;
writableflag to be confirmed in phase 0)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:
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):docs/site-convention.md).Host API: add
host.http_patch(url, body, headers)(or a generichttp_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 havehttp.Opt-in — all three must hold before a single write happens:
capabilities.http.allow_write: trueinconfig.yaml(host-enforced)config.write.solar_pv: trueSafety (per
docs/clamping.md, every clamp answers a quantifiable risk):[0, PV nameplate]— risk: a sign bug or telemetry spike telling the pump there are 100 kW of surplus.0once, then stop — mirrors the existing stale-meter dispatch guard. Risk: pump chasing yesterday's sunshine.DefaultMode→ stop writing. Absence of writes must be the safe state; phase 0 confirms how the pump times out a silent feed.Phases
Phase 0 — groundwork (no writes yet)
writable: truepoints from the live catalog (the metadata already carries the flag; one filtered bulk GET)https://<ip>:8443/— PATCH body shape, whether any pump-side mode must be enabled first, rate limitsPhase 1 — Solar PV feed (core of this issue)
host.http_patch+http.allow_writecapability + a capability-denied test (pattern:TestLuaHTTPCapabilityNotGranted)driver_commandinnibe_local.luaimplementing thesolar_pvaction (and updating the "observe-only" header contract)configuration.md,safety.md, driver docs)Phase 2 — electric add-heat (immersion heater) power limit
Phase 3 — parked ideas (split into their own issues when reached)
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.