feat(web): turn a driver's write path on from Settings - #769
Conversation
Everything in the catalog reads. One driver can also write — the NIBE
S-series solar surplus feed — and arming it meant hand-editing two keys in
config.yaml: config.write.solar_pv on the driver and
capabilities.http.allow_write on the host. An owner could install the driver
from a card in the UI and then had no way to use the one thing it was built
for.
A driver now names its write paths in its DRIVER block
(write_capabilities = { "solar_pv" }), and Settings -> Devices fills a slot
on the drivers that declare one: a switch and the maximum surplus to report.
A driver that declares nothing gets no markup at all, which is the reason for
a slot rather than a hidden fieldset — inert inputs are still read on save,
and would write a write-block into the config of every driver that has no
write path.
One switch moves both gates. Holding one without the other never wrote
anything — the host refuses the verb without the grant, and the driver
disables the feed without the verb — so a half-armed config reads as off and
saving it off changes no behaviour. The feed will not arm without a maximum
above 0, and clearing that maximum disarms a running feed: the ceiling is
what stops a sign error or a telemetry spike from telling a pump there are
100 kW going spare. What the pump needs at its own end (menu 7.5.15 set to
read/write, Solar PV input on) cannot be checked from here, so the panel
says it.
Verified against a local run at the pinned driver snapshot: the catalog
reports write_capabilities from the DRIVER block, and a config carrying both
gates round-trips POST /api/config -> config.yaml -> masked GET with the
pump password preserved.
The local-API help text also stopped telling NIBE owners to enable the API in
the myUplink app. It is generated on the pump's own screen; no app and no
cloud account are involved.
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
The write path existed and had no way to be turned on except by hand-editing two keys in config.yaml on the gateway — config.write.solar_pv here, and capabilities.http.allow_write on the host. FTW's Settings screen now renders a switch for a write path, but only for a driver that states it has one (srcfl/ftw#769), which is what keeps that switch off every read-only driver in the catalog. write_capabilities is metadata, not a grant: declaring the path is what makes it offerable, and the feed still needs the operator to turn it on, a max_w ceiling, and the pump's own Solar PV input enabled. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
|
Update: the PR body's "no write was sent to any heat pump" is now superseded A dev build of this branch was deployed to a live site (host-mounted over the Full findings with the log/telemetry sequence: see the HIL comment on #537. 🤖 Generated with Claude Code |
# Conflicts: # docs/writing-a-driver.md # go/internal/drivers/catalog.go
The write path existed and had no way to be turned on except by hand-editing two keys in config.yaml on the gateway — config.write.solar_pv here, and capabilities.http.allow_write on the host. FTW's Settings screen now renders a switch for a write path, but only for a driver that states it has one (srcfl/ftw#769), which is what keeps that switch off every read-only driver in the catalog. write_capabilities is metadata, not a grant: declaring the path is what makes it offerable, and the feed still needs the operator to turn it on, a max_w ceiling, and the pump's own Solar PV input enabled. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> Signed-off-by: Hugo Karlsson <48095810+HuggeK@users.noreply.github.com>
|
The switch, rendered live. This is the panel this PR adds, captured on the real S735 validation host (dev build of this branch + device-drivers#46 driver) during the HIL run — Settings → Devices → NIBE card, with the feed armed at a 100 W ceiling: How this was capturedHeadless-browser screenshot of the running instance (no mockup): the probe confirmed 🤖 Generated with Claude Code |
miravoss26
left a comment
There was a problem hiding this comment.
Adds an opt-in write_capabilities catalog field so Settings → Devices can offer a switch for a driver's declared write path (NIBE solar-PV surplus feed) instead of requiring hand-edited YAML. A driver that declares nothing gets no panel and no capturable inputs, so read-only drivers stay untouched — verified with a dedicated Go catalog test and 8 JS tests, plus a manual round-trip against a local gateway (TEST-NET-1 address, no real hardware touched).
- The single visible toggle drives a hidden
allow_writegrant checkbox in sync, and the feed is blocked from (re-)arming without a ceiling > 0 on both change and initial load — matches the safety framing in the PR body. escHtml()is applied to the one user-influenced rendered value (max_w); no injection surface I could find.- No secrets, no new deps, no new network destinations.
Safe to merge from my read.
Enabling the Settings switch now actually feeds the pump: every control tick, core computes the site's solar-attributable export — min(live PV generation, grid export minus battery/V2X discharge), so stored energy is never advertised as sunshine — and sends it site-signed as a `solar_pv` command to every driver whose operator armed the write gates (allow_write + write.solar_pv + max_w > 0). The hint repeats every tick on purpose: repetition feeds the driver's dead-man switch, and the driver already deadbands, rate-limits and clamps the device writes. Dispatch sits behind the existing site-meter freshness gate, and a stale gate already walks every driver to its default mode, which clears the device register. Standing refusals (the pump-side enable register still off) log once per transition, not once per tick. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
One conflict: srcfl#805 moved the shared driverActuationTracker init earlier in main(); kept upstream's placement and this branch's solarFeedSender init beside the stale-site tracker. Also answers srcfl#805's per-path question for the new solar feed: a refused solar_pv hint does not count toward driver_cannot_actuate — no power was asked for — documented in the tracker's path enumeration. Known local-only: TestStaleDriverIsLeftToTheWatchdog (new in srcfl#805) flakes on Windows' coarse clock (WatchdogScan(1ns) vs same-tick RecordSuccess); passes on the Linux runners. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
The panel and docs told owners to 'turn on the Solar PV input' without saying where a pump hides it. Now both name the screens: installer menu 7.5.15 for read/write, and menu 4.2.2 'solar electricity' (a plus function, added from 7.2.1 when missing) for the enable itself — which the pump titles 'Modbus TCP/IP Ext. (Solar PV)', register 2107, read live off an S735. The docs also gain the readback tell that the pump is actually listening: hp_total_average_power_solar_pv (register 2178) starts tracking the fed value. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
miravoss26
left a comment
There was a problem hiding this comment.
Adds a Settings UI switch (and the core dispatch loop behind it) to let an operator arm the NIBE Solar PV surplus write path, instead of hand-editing config.write.solar_pv + capabilities.http.allow_write in YAML. Core now computes min(live PV, export − battery/V2X discharge) every control tick and sends it to any driver whose write gates are armed.
Correctness — read the full diff (Go + JS). The gating logic in solarFeedDriversFrom/fillWriteSlot mirrors the driver's own refusal conditions exactly (host grant + driver opt-in + ceiling > 0, one switch moves both gates together), so a UI-armed config can't diverge from what the driver would accept. solarSurplusW's clamping (cap at PV generation, subtract battery/V2X discharge, floor at 0) is covered by a solid table of unit tests including the edge cases that matter most — importing, offline PV driver, missing meter, charging vs. discharging battery. The "no ceiling → no feed" UI behavior and "clearing the ceiling disarms" are both tested in driver-write-feed.test.mjs. CI green across Go/optimizer/web.
Security screen — this is a new write path to physical hardware, so it gets the closest look: bounded by an explicit operator-set ceiling, gated behind two independent switches that both default off, degrades to 0 on stale/missing telemetry (safe direction), and reuses the existing dead-man-switch/default-mode-clear machinery rather than adding new safety plumbing. No secrets in the diff, no new network destinations (existing NIBE local API), no injection surface (payload is a JSON-encoded float). Docs (nibe-local.md) spell out the device-side menus an operator still has to set by hand, since FTW can't verify them.
One thing worth a human's eyes rather than mine: the panel doesn't render yet since the bundled driver doesn't declare write_capabilities (waiting on srcfl/device-drivers#46) — worth confirming that PR lands before this reaches users, but that's sequencing, not a defect here.
Safe to merge from my read.
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>

Every driver in the catalog reads. One of them can also write — the NIBE
S-series solar surplus feed from #537 — and turning it on meant hand-editing two
separate keys in
config.yamlon the gateway:config.write.solar_pvon thedriver, and
capabilities.http.allow_writeon the host. Neither has a controlin Settings. An owner could install the driver from a card in the UI and then
had no way to use the one thing it was built for.
A driver now names its write paths in its own
DRIVERblock —write_capabilities = { "solar_pv" }— and Settings → Devices fills aSolar PV surplus feed panel on the drivers that declare one: a switch, and the
maximum surplus to report. A driver that declares nothing gets no panel, so
every read-only driver in the catalog is untouched.
Why a slot filled after the catalog resolves, rather than a hidden fieldset
The catalog arrives after the first render, so the tab already renders-then-
reveals for the credential fields. Reveal is the wrong shape here: a hidden
fieldset still has its inputs read by
captureCurrentTab, so every HTTP driverin the config would come back from a save carrying
write: {solar_pv: false, max_w: 0}and anallow_writekey — a write-block written into the config ofdrivers that have no write path at all. The
.drv-write-slotstays emptymarkup until the driver is known to declare the path, which is also what
happens if the catalog fetch fails: nothing is rendered and nothing is
captured.
The switch keeps the properties the YAML had, where they can be seen
One switch moves both gates. Holding one without the other never wrote
anything — the host refuses
http_patchwithout the grant, and the driverdisables the feed when
host.http_patchis absent — so a half-armed configrenders as off, and saving it off changes no behaviour. Two checkboxes would
only invent a state that has to be explained.
No ceiling, no feed. The panel will not arm without a maximum above 0 W, and
clearing the maximum disarms a running feed. That ceiling is what stops a sign
error or a telemetry spike from telling a pump there are 100 kW going spare. The
driver already refuses this — in a log line, an hour later; refusing it where
the operator is looking is the same rule, findable.
The pump half is stated, not assumed — with the screens named. Installer
menu 7.5.15 set to read/write and the Solar PV input turned on cannot be
checked from FTW. Without saying so, ticking the box gets silent per-point
read only valueerrors. So the panel anddocs/nibe-local.mdname the exactscreens: menu 7.5.15, and menu 4.2.2 "solar electricity" for the enable, which
the pump titles Modbus TCP/IP Ext. (Solar PV) — register 2107, names read
live off an S735. The decommissioning note is there for the same reason: once
FTW is gone, nothing is left to clear a stale value.
Core now sends the value — the switch is end-to-end
Until this branch, arming the feed configured the gates but nothing in core
ever issued the
solar_pvcommand; the driver's write path only ran whensomething external called it. Now the control tick forwards the site's live
solar surplus to every driver whose operator armed the write gates
(
allow_write+write.solar_pv+max_w > 0):min(live PV generation, grid export − battery/V2X discharge), floored at 0, site-signed. Both clamps guard a realmisattribution — a battery arbitraging into the grid must not have its watts
advertised to the pump as free sunshine to burn.
Repetition is deliberate — it is what feeds the driver's dead-man switch; the
driver deadbands and rate-limits the actual device writes (validated on
hardware in the Opt-in writable NIBE driver: feed PV production to the Solar PV registers (2107/2108/2109), later cap the electric add-heat #537 HIL run).
driver to its default mode, which clears the pump-side register — the same
clear the dead-man enforces if commands stop arriving. No new safety
machinery, the invariants that were already load-bearing carry this too.
enable register still off), and the command repeats every few seconds, so
refusals log on transition only — first refusal, changed refusal, recovery.
ComputeSolarFeedlives ininternal/controlbesideComputePVCurtailandmirrors its shape (config-derived driver set on
State, hot-reloadable, purefunction over the telemetry store). Eleven Go tests cover the surplus
arithmetic (battery/V2X exclusion, offline-driver exclusion, PV capping,
import → 0) and the config gates + log latch.
Verified against a running gateway, not only by reading
What was checked, and how
Local run at the pinned driver snapshot,
GET /api/drivers/catalog:{ "id": "nibe-local", "capabilities": ["apicreds"], "config_secrets": ["password"], "connection_defaults": {"port": 8443} }— which is what makes the pump fully installable from the UI today: Host/IP,
Username, Password (from
config_secrets) and the certificate fingerprint allrender, the driver defaults the port to 8443, and the backend derives
allowed_hostsfromconfig.host. Addingwrite_capabilities = { "solar_pv" }to that driver made the field appear in the same catalog response without a
restart.
A config carrying both gates round-trips
POST /api/config→config.yaml→masked
GET, with the pump password preserved across a save that did not touchit:
Eight tests in
web/driver-write-feed.test.mjscover the rules above, and oneGo test covers the catalog field. No write was sent to any heat pump.
Notes for review
write_capabilitiesyet, so the paneldoes not appear until feat(nibe_local): opt-in Solar PV surplus write path (srcfl/ftw#537) device-drivers#46 lands with the declaration and
ships through the driver channel. That is the intended order: the host offers
a write switch only where the installed driver says it has a write path.
web/settings/tabs/devices.js(two help-text lines); thisbranch adds a slot and a module-level function elsewhere in the file and
rebases cleanly on it either way.
app. It is generated on the pump's own screen — same correction as docs(nibe): the Local REST API is enabled on the pump, not in the app #737, which
fixes the prose in
docs/nibe-local.md; this fixes it in the UI.internal/apitests(backup lifecycle, draft override, MyUplink OAuth) and
web/theme-tokens.test.mjsfail identically on a clean
masterworktree; thecmd/ftwnovakey-storage tests fail on Windows file-ownership metadata; and
TestStaleDriverIsLeftToTheWatchdog(new in fix(control): every dispatch path reports whether the driver took the command #805) flakes on Windows'coarse clock —
WatchdogScan(time.Nanosecond)can land on the sameclock tick as the preceding
RecordSuccess, so the driver never goesoffline. Fails ~half of
-count=20runs here at upstream/master too;fine on the Linux runners.
Closes #537
🤖 Generated with Claude Code