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
Activity
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:
- Driver (Solar PV feed) → srcfl/device-drivers#46
- Core (
host.http_patch+capabilities.http.allow_write) → srcfl/ftw#716
The 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).
Class Registers Why Safe / self-limiting 2109 Available power (+2107 enable, owner-side) Control-by-hint: pump firmware decides; effect bounded by owner-tuned offsets (max +10 curve). Stuck value ⇒ wasted comfort, never unsafe. Chosen for phase 1. Safe / one-shot 697 "More hot water" boost (countdown in 225) Bounded single boost. Parked (phase 3). Guarded / bounded 30 heating offset (±10) · 206 room setpoint (5–30 °C) · 51/55 external adjustment (needs AUX activation) · 3032+6008 SG-Ready via API (fw 4.7.5+) · 6007/6012 power limitation Persistent until rewritten: a stuck write over/under-heats or caps capacity in a cold snap. SG-Ready state 0 (Blocking) is effectively a persistent compressor block — treat as dangerous. Dangerous / persistent 11 degree minutes (forced compressor starts/stops ⇒ short-cycling wear) · 2743 operating mode (add-heat-only = 3–5× cost) · 56–67 DHW temps & periodic hot water (legionella) · 26/39–47 heat curve · 2740–2742 AUX blocks (frozen pipes if stuck) Real hardware/health risk from a stuck or malicious write. Not for an optimizer. Forbidden 22 alarm reset + DELETE /notifications·POST /aidmode· 2755 inverter fault reset · 137/139 defrost · 5217–5225 external sensor overridesMasks faults or feeds the pump fake sensor data. Never. Notably 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
- Solar-feed forwarder in the core dispatch loop (clamp + stale-telemetry guard + deadband at the source, per the sketch — the driver-side guards are defense in depth, not a replacement)
- UI: feed status on the heat-pump card
- HIL on the S735: confirm the pump-side silent-feed timeout, confirm 2109 is RAM-backed, decide 2108 semantics (gross vs net — still leaning net)
- Phase 2 (add-heat power cap) and phase 3 (hot-water boost) unchanged
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:
- Driver → srcfl/device-drivers#46 —
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). - Core → srcfl/ftw#716 —
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:
- confirm register 2107 ("Modbus TCP/IP Ext. / Solar PV") can be enabled on your pump and 2109 ("Available power") accepts a write over the Local REST API (installer menu 7.5.15 set to read/write);
- confirm the pump actually soaks surplus into heating/hot-water via its offsets when fed a non-zero 2109;
- the key unknown: how long the pump holds a stale 2109 value after the feed stops (the driver clears it via a dead-man's switch because this timeout is undocumented — we'd like to measure the real number);
- whether 2109 is RAM-backed (survives a reboot or not) and the intended 2108 ("include own consumption") semantics.
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.- Driver → srcfl/device-drivers#46 —
- added a commit that references this issue
on Jul 30, 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:
- The pump accepts the write. One PATCH of 40 W to the available-power
point (register 2109) came backmodified— 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. - The full FTW chain works. The write went through the deployed core's
capabilities.http.allow_writegate, the per-driver host allowlist, the
pinned self-signed TLS cert, and thehost.http_patchverb from feat(drivers): host.http_patch write verb for device REST writes (#537) #716 —
no step needed adjustment. - The value verifiably lands. The driver's own next poll read register
2109 back through the bulk points GET: the telemetry series shows
0 → 40 → 0, exactly bracketing the write and the later clear. - The dead-man's switch fires. With
write.ttl_s: 120and no further
commands, the driver cleared the feed autonomously at +124 s
(feed stale — dead-man's switch). Observed twice. - The orphan sweep recovers a crash. With 40 W standing, the FTW
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. - The catalog/UI path connects. The driver's
write_capabilities = { "solar_pv" }declaration surfaced through
/api/drivers/catalog(source: local) and the Settings → Devices panel
from 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
- Core: dev build of the feat(web): turn a driver's write path on from Settings #769 branch (master + catalog/UI changes),
host-mounted over the site's v1.15.0 compose install via a
docker-compose.override.ymlbind — the layout, data directory and the
rest of the stack untouched. - Driver: the exact feat(nibe_local): opt-in Solar PV surplus write path (srcfl/ftw#537) device-drivers#46 artifact
(sha256 69a74a82…) as a local override in the user-drivers directory. - The core-side solar-feed forwarder does not exist yet, so nothing sends
driver_command("solar_pv", …)in a stock system. For the test only, the
local override carried a config-gated one-shot self-test
(write.self_test_w: 40) that pushed one value through the standard
write_pv_surpluspath and armed the standard dead-man's switch. It was
removed afterwards; the site now runs the clean fix: 5 Go-side P1 bugs from Codex review #46 artifact. - Config was armed through the same masked-config round-trip the Settings
UI uses (POST /api/config); the pump credentials never left the site. - Register 2107 (the owner's Solar PV master switch) was off throughout,
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
- The pump accepts the write. One PATCH of 40 W to the available-power
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
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.