Skip to content

feat(web): turn a driver's write path on from Settings - #769

Open
HuggeK wants to merge 6 commits into
srcfl:masterfrom
HuggeK:nibe-solar-pv-ui
Open

feat(web): turn a driver's write path on from Settings#769
HuggeK wants to merge 6 commits into
srcfl:masterfrom
HuggeK:nibe-solar-pv-ui

Conversation

@HuggeK

@HuggeK HuggeK commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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.yaml on the gateway: config.write.solar_pv on the
driver, and capabilities.http.allow_write on the host. Neither has a control
in 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 DRIVER block —
write_capabilities = { "solar_pv" } — and Settings → Devices fills a
Solar 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 driver
in the config would come back from a save carrying write: {solar_pv: false, max_w: 0} and an allow_write key — a write-block written into the config of
drivers that have no write path at all. The .drv-write-slot stays empty
markup 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_patch without the grant, and the driver
disables the feed when host.http_patch is absent — so a half-armed config
renders 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 value errors. So the panel and docs/nibe-local.md name the exact
screens: 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_pv command; the driver's write path only ran when
something 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):

  • What is sent: min(live PV generation, grid export − battery/V2X discharge), floored at 0, site-signed. Both clamps guard a real
    misattribution — a battery arbitraging into the grid must not have its watts
    advertised to the pump as free sunshine to burn.
  • When: every control tick, behind the existing site-meter freshness gate.
    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).
  • When telemetry goes stale: the freshness gate already reverts every
    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.
  • Log discipline: a refused feed can be a steady state for days (pump-side
    enable register still off), and the command repeats every few seconds, so
    refusals log on transition only — first refusal, changed refusal, recovery.

ComputeSolarFeed lives in internal/control beside ComputePVCurtail and
mirrors its shape (config-derived driver set on State, hot-reloadable, pure
function 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 all
render, the driver defaults the port to 8443, and the backend derives
allowed_hosts from config.host. Adding write_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/configconfig.yaml
masked GET, with the pump password preserved across a save that did not touch
it:

capabilities:
  http:
    allow_write: true
config:
  host: 192.0.2.10          # TEST-NET-1, nothing was pointed at real hardware
  write:
    max_w: 9000
    solar_pv: true

Eight tests in web/driver-write-feed.test.mjs cover the rules above, and one
Go test covers the catalog field. No write was sent to any heat pump.

Notes for review

Closes #537

🤖 Generated with Claude Code

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>
HuggeK added a commit to HuggeK/device-drivers that referenced this pull request Aug 3, 2026
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>
@HuggeK

HuggeK commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Update: the PR body's "no write was sent to any heat pump" is now superseded
— this branch has since been run against a real S-series pump, and the write
path it exposes works end-to-end.

A dev build of this branch was deployed to a live site (host-mounted over the
stock compose install), with the srcfl/device-drivers#46 driver as a local
override. write_capabilities: ["solar_pv"] surfaced through
/api/drivers/catalog exactly as the panel expects, the armed config
round-tripped through the same masked POST /api/config path the panel
saves through (pump password preserved untouched), and a real 40 W write to
register 2109 was accepted by the pump, read back in telemetry, cleared by
the dead-man's switch, and — after a SIGKILL mid-feed — cleared again by the
startup orphan sweep.

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
@HuggeK
HuggeK marked this pull request as ready for review August 4, 2026 18:21
@HuggeK
HuggeK requested a review from frahlg as a code owner August 4, 2026 18:21
HuggeK added a commit to HuggeK/device-drivers that referenced this pull request Aug 4, 2026
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>
@HuggeK

HuggeK commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

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:

Settings → Devices showing the Solar PV surplus feed panel armed on the live Pi

How this was captured

Headless-browser screenshot of the running instance (no mockup): the probe confirmed panel rendered; toggle=true max_w=100 before capture, i.e. the checkbox state and ceiling shown are the values round-tripped through /api/config. The panel only exists because the local driver declares write_capabilities = { "solar_pv" } — a driver without the declaration gets an empty slot, per the PR description. The host has since been restored to the stock v1.15.0 image with write gates removed; the screenshot documents the UI, not a standing configuration.

🤖 Generated with Claude Code

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_write grant 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.

claude and others added 3 commits August 5, 2026 11:42
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 miravoss26 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opt-in writable NIBE driver: feed PV production to the Solar PV registers (2107/2108/2109), later cap the electric add-heat

3 participants