From af6565824a5fde737787d472c086f5fce38a75cb Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Mon, 3 Aug 2026 21:59:12 +0200 Subject: [PATCH 1/3] feat(web): turn a driver's write path on from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .changeset/solar-pv-write-switch.md | 34 +++++ docs/nibe-local.md | 9 ++ docs/writing-a-driver.md | 9 ++ go/internal/drivers/catalog.go | 14 ++ go/internal/drivers/catalog_test.go | 36 ++++++ web/driver-write-feed.test.mjs | 191 ++++++++++++++++++++++++++++ web/settings/tabs/devices.js | 116 ++++++++++++++++- 7 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 .changeset/solar-pv-write-switch.md create mode 100644 web/driver-write-feed.test.mjs diff --git a/.changeset/solar-pv-write-switch.md b/.changeset/solar-pv-write-switch.md new file mode 100644 index 00000000..cf691cf2 --- /dev/null +++ b/.changeset/solar-pv-write-switch.md @@ -0,0 +1,34 @@ +--- +"ftw": minor +--- + +A driver's opt-in write path can be turned on from Settings, instead of by +hand-editing two keys in config.yaml. + +Everything in the catalog reads. One driver can also write — the NIBE S-series +solar surplus feed — and arming it meant setting `config.write.solar_pv` on the +driver *and* `capabilities.http.allow_write` on the host, neither of which the +settings screen offered. 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 grows 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 and no +markup, so read-only drivers are untouched and nothing about writing is written +into their config. + +The panel keeps the safety properties the YAML had, where an operator can see +them. One switch moves both gates, because holding one without the other never +wrote anything anyway — 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. The feed will not arm without a maximum above 0: that ceiling is what +stops a sign error or a telemetry spike from telling a pump there are 100 kW +going spare, and clearing it disarms a running feed. What the pump needs at its +own end — installer menu 7.5.15 set to read/write, its Solar PV input on — +cannot be checked from FTW, so the panel says so rather than letting the writes +fail silently as `read only value`. + +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; there is no app and +no cloud account in that path. diff --git a/docs/nibe-local.md b/docs/nibe-local.md index 09c2d541..2fb802fe 100644 --- a/docs/nibe-local.md +++ b/docs/nibe-local.md @@ -21,6 +21,15 @@ the signed driver channel, stays off without `write.solar_pv: true` plus `capabilities.http.allow_write: true`, and needs a core with `host.http_patch`. +Once a driver that declares that path is installed (`write_capabilities` in +its `DRIVER` block), **Settings → Devices** grows a *Solar PV surplus feed* +panel on it: one switch and the maximum surplus to report. The switch sets +both gates together, because either alone does nothing, and the feed stays +off until the maximum is above 0 — that ceiling is what bounds every value +FTW can send. A driver that declares no write path gets no panel. What the +pump needs at its own end — menu 7.5.15 set to read/write, Solar PV input on +— cannot be checked from FTW, so the panel states it. + **Decommissioning a write-enabled setup.** Every automatic safeguard around the feed (dead-man's switch, default-mode clear, startup orphan sweep) runs inside FTW — none of them can fire once FTW is gone, and the pump's own diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 7a2628b3..f8d435de 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -37,6 +37,15 @@ version, host API and `read_only` value must agree with the signed package. Do not duplicate the catalog in Markdown. Executable or public metadata changes require one public package version bump. +A driver with an opt-in write path names it in `write_capabilities` — e.g. +`write_capabilities = { "solar_pv" }` for a driver that feeds a heat pump's +own solar-surplus input. The Settings UI offers a switch for a path it +recognises and nothing for a driver that declares none, so a write stays +unreachable until the driver itself says it has one. Declaring a path is not +enabling it: the switch is off until an operator turns it on, and the host +capability it needs (`capabilities.http.allow_write`) is still a separate +grant. + ## Lifecycle ```lua diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 3309b79b..91e4558e 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -66,6 +66,19 @@ type CatalogEntry struct { // reads `config.` like any other entry — this is purely a // hint for the UI layer. ConfigSecrets []string `json:"config_secrets,omitempty"` + + // WriteCapabilities names the opt-in write paths a driver implements, + // so the Settings UI can offer a switch for one instead of leaving it + // to hand-edited YAML. Each entry is a stable identifier the UI knows + // how to render a control for (today: "solar_pv" — the NIBE S-series + // surplus feed behind `config.write.solar_pv` + `capabilities.http. + // allow_write`). + // + // A driver that declares nothing gets no write UI, which is the point: + // the switch appears only where the installed driver states it has a + // write path, rather than FTW matching on a filename or vendor name. + // Read-only remains the default for every driver in the catalog. + WriteCapabilities []string `json:"write_capabilities,omitempty"` } // LoadCatalog scans dir (and any direct sub-directories) for .lua driver @@ -194,6 +207,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) { e.VerificationNotes = pickString(block, "verification_notes") e.TestedModels = pickList(block, "tested_models") e.ConfigSecrets = pickList(block, "config_secrets") + e.WriteCapabilities = pickList(block, "write_capabilities") return e, nil } diff --git a/go/internal/drivers/catalog_test.go b/go/internal/drivers/catalog_test.go index 6b317217..890cf178 100644 --- a/go/internal/drivers/catalog_test.go +++ b/go/internal/drivers/catalog_test.go @@ -98,3 +98,39 @@ func TestLoadCatalogMultiMissingDirSkipped(t *testing.T) { t.Fatalf("want 1 entry from bundledDir, got %d", len(entries)) } } + +// A write path only reaches the Settings UI if the driver says it has one. +// The alternative — FTW recognising "the NIBE driver can write" by filename +// — puts vendor knowledge in the host and silently arms a write switch for +// any file that happens to be named the same. +func TestLoadCatalogReadsWriteCapabilities(t *testing.T) { + dir := t.TempDir() + writer := "DRIVER = {\n id = \"nibe-local\",\n name = \"NIBE\",\n" + + " write_capabilities = { \"solar_pv\" },\n}\n" + if err := os.WriteFile(filepath.Join(dir, "nibe_local.lua"), []byte(writer), 0644); err != nil { + t.Fatal(err) + } + // A driver that mentions writing in prose still declares nothing. + reader := "-- writes nothing; write_capabilities stays unset\n" + + "DRIVER = {\n id = \"reader\",\n name = \"Reader\",\n}\n" + if err := os.WriteFile(filepath.Join(dir, "reader.lua"), []byte(reader), 0644); err != nil { + t.Fatal(err) + } + + entries, err := LoadCatalog(dir) + if err != nil { + t.Fatalf("LoadCatalog: %v", err) + } + byID := make(map[string]CatalogEntry, len(entries)) + for _, e := range entries { + byID[e.ID] = e + } + + got := byID["nibe-local"].WriteCapabilities + if len(got) != 1 || got[0] != "solar_pv" { + t.Errorf("nibe-local WriteCapabilities = %v, want [solar_pv]", got) + } + if len(byID["reader"].WriteCapabilities) != 0 { + t.Errorf("reader declared no write path but got %v", byID["reader"].WriteCapabilities) + } +} diff --git a/web/driver-write-feed.test.mjs b/web/driver-write-feed.test.mjs new file mode 100644 index 00000000..5433f96e --- /dev/null +++ b/web/driver-write-feed.test.mjs @@ -0,0 +1,191 @@ +// Turning a driver's write path on, from the settings screen. +// +// Every driver in the catalog reads. One of them can also write — the NIBE +// S-series solar surplus feed — and turning it on meant hand-editing two +// separate keys in config.yaml on the gateway, one of them a host capability +// most owners never see. An owner who can install the driver from a card in +// the UI could not then use the one thing it was built for. +// +// What these tests hold in place is not the fieldset but its rules: it exists +// only where the driver itself declares the write path, both gates move +// together, and neither moves at all without a ceiling on what can be sent. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = readFileSync(new URL("./settings/tabs/devices.js", import.meta.url), "utf8"); + +function load() { + const window = { FTWSettings: { tabs: {} } }; + vm.runInNewContext(source, { + document: { + createElement: () => ({ style: {}, dataset: {}, appendChild() {}, addEventListener() {} }), + getElementById: () => null, + }, + fetch: async () => ({ ok: true, json: async () => ({}) }), + window, + }); + return window.FTWSettings.writeFeed; +} + +// Just enough of the DOM to run the fieldset the tab writes into a slot: +// the inputs it puts there, and the listeners it hangs off them. +function parseNodes(html) { + const nodes = []; + for (const [, tag, rawAttrs] of html.matchAll(/<(input|p)\b([^>]*)>/g)) { + const attrs = {}; + for (const [, name, value] of rawAttrs.matchAll(/([a-z-]+)(?:="([^"]*)")?/g)) { + attrs[name] = value === undefined ? "" : value; + } + const listeners = new Map(); + nodes.push({ + tag, + className: attrs.class || "", + type: attrs.type || "", + value: attrs.value || "", + checked: "checked" in attrs, + hidden: "hidden" in attrs, + textContent: "", + addEventListener(name, handler) { listeners.set(name, handler); }, + fire(name) { const handler = listeners.get(name); if (handler) handler(); }, + }); + } + return nodes; +} + +function slot() { + let html = ""; + let nodes = []; + return { + get innerHTML() { return html; }, + set innerHTML(value) { html = value; nodes = parseNodes(value); }, + querySelector(selector) { + const wanted = selector.replace(/^\./, ""); + return nodes.find((n) => n.className.split(" ").includes(wanted)) || null; + }, + }; +} + +const OPTS = { idx: 0, help: () => "", escHtml: (s) => String(s) }; +const WRITER = { write_capabilities: ["solar_pv"] }; + +function nibe(overrides) { + return Object.assign({ + name: "nibe", + lua: "drivers/nibe_local.lua", + capabilities: { http: { allowed_hosts: ["192.168.2.178:8443"] } }, + config: { host: "192.168.2.178", username: "local-api" }, + }, overrides || {}); +} + +test("a driver that declares no write path gets no write markup at all", () => { + const feed = load(); + const target = slot(); + assert.equal(feed.fill(target, nibe(), { capabilities: ["apicreds"] }, OPTS), false); + assert.equal(target.innerHTML, "", + "an inert hidden fieldset would still be read on save, writing a write-block " + + "into the config of a driver that has no write path"); + assert.equal(feed.fill(target, nibe(), undefined, OPTS), false, + "a catalog that has not resolved yet is not a licence to render the switch"); +}); + +test("one switch moves both gates, because either alone does nothing", () => { + const feed = load(); + const target = slot(); + feed.fill(target, nibe(), WRITER, OPTS); + assert.match(target.innerHTML, /data-checkbox-path="drivers\.0\.config\.write\.solar_pv"/); + assert.match(target.innerHTML, /data-checkbox-path="drivers\.0\.capabilities\.http\.allow_write"/); + assert.match(target.innerHTML, /data-path="drivers\.0\.config\.write\.max_w"/); + + const toggle = target.querySelector(".drv-write-toggle"); + const grant = target.querySelector(".drv-write-grant"); + const ceiling = target.querySelector(".drv-write-max"); + assert.equal(toggle.checked, false, "a driver arrives read-only"); + assert.equal(grant.checked, false); + + ceiling.value = "9000"; + toggle.checked = true; + toggle.fire("change"); + assert.equal(grant.checked, true, "the host grant follows the switch the operator sees"); +}); + +test("no ceiling, no feed", () => { + const feed = load(); + const target = slot(); + feed.fill(target, nibe(), WRITER, OPTS); + const toggle = target.querySelector(".drv-write-toggle"); + const grant = target.querySelector(".drv-write-grant"); + const warning = target.querySelector(".drv-write-warning"); + + toggle.checked = true; + toggle.fire("change"); + assert.equal(toggle.checked, false, "the ceiling bounds every value FTW can send"); + assert.equal(grant.checked, false); + assert.equal(warning.hidden, false); + assert.match(warning.textContent, /maximum above 0 W/); +}); + +test("clearing the ceiling disarms a running feed", () => { + const feed = load(); + const target = slot(); + feed.fill(target, nibe({ + capabilities: { http: { allow_write: true } }, + config: { host: "192.168.2.178", write: { solar_pv: true, max_w: 9000 } }, + }), WRITER, OPTS); + const toggle = target.querySelector(".drv-write-toggle"); + const ceiling = target.querySelector(".drv-write-max"); + assert.equal(toggle.checked, true, "both gates set means the feed is armed"); + assert.equal(ceiling.value, "9000"); + + ceiling.value = ""; + ceiling.fire("input"); + assert.equal(toggle.checked, false); + assert.equal(target.querySelector(".drv-write-grant").checked, false); +}); + +test("a half-armed config reads as off, because it never wrote anything", () => { + const feed = load(); + const target = slot(); + // config.write.solar_pv without the host grant: the driver disables the + // feed on its own when host.http_patch is missing, so showing this as on + // would claim something that was never true. + feed.fill(target, nibe({ + config: { host: "192.168.2.178", write: { solar_pv: true, max_w: 9000 } }, + }), WRITER, OPTS); + assert.equal(target.querySelector(".drv-write-toggle").checked, false); + assert.equal(target.querySelector(".drv-write-grant").checked, false); +}); + +test("an armed config with no usable ceiling shows as off, with the reason", () => { + const feed = load(); + const target = slot(); + feed.fill(target, nibe({ + capabilities: { http: { allow_write: true } }, + config: { host: "192.168.2.178", write: { solar_pv: true, max_w: 0 } }, + }), WRITER, OPTS); + assert.equal(target.querySelector(".drv-write-toggle").checked, false, + "the driver would refuse this config; the screen must not claim otherwise"); + assert.equal(target.querySelector(".drv-write-warning").hidden, false); +}); + +test("the panel states what has to be done on the pump", () => { + const feed = load(); + const target = slot(); + feed.fill(target, nibe(), WRITER, OPTS); + // Nothing in FTW can read the pump's installer menu, so an operator who + // only ticks the box here would get silent per-point "read only value" + // errors with no idea why. + assert.match(target.innerHTML, /7\.5\.15/); + assert.match(target.innerHTML, /Solar PV/); + assert.match(target.innerHTML, /read only value/); +}); + +test("local-API setup does not send owners to an app they never used", () => { + // The NIBE Local REST API account is generated on the pump's own screen. + // The help text said myUplink, which is a different product with a + // different account, and following it is a dead end. + assert.ok(!/local-API account you set up in the myUplink app/.test(source)); + assert.match(source, /the account the pump generates on its own screen/); +}); diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index c1e6f3b8..ef6b63d7 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -19,6 +19,103 @@ return caps.indexOf(capability) >= 0; } + // ---- Opt-in write path ------------------------------------------------- + // + // Drivers read. One can also write, and only where its own catalog entry + // says so: `write_capabilities` in the driver's DRIVER block. Today the + // single member is "solar_pv" — the NIBE S-series surplus feed + // (srcfl/ftw#537), where FTW keeps the pump's own "available power" input + // updated and the pump's firmware decides what to do with the number, so a + // wrong value costs comfort rather than safety. + // + // Two switches must agree before a write can leave the host: the host + // grant (`capabilities.http.allow_write`, which is what unlocks + // host.http_patch) and the driver opt-in (`config.write.solar_pv`). + // Holding one without the other does nothing, so one checkbox sets both — + // splitting them across two boxes would only invent a half-armed state to + // explain. What the pump needs at its own end is stated in the panel, + // because nothing here can check it. + // + // Lives at module scope, and fills a slot rather than rendering inline, for + // two reasons: the catalog resolves after the first render, and an inert + // hidden fieldset would still have its inputs read on save — writing a + // write-block into the config of every driver that has no write path. + function fillWriteSlot(slot, driver, entry, opts) { + var writes = (entry && entry.write_capabilities) || []; + if (writes.indexOf("solar_pv") < 0) return false; + var idx = opts.idx, help = opts.help, escHtml = opts.escHtml; + var wcfg = (driver.config && driver.config.write) || {}; + var httpCap = (driver.capabilities && driver.capabilities.http) || {}; + // Armed means both switches are on. A config carrying only one of them + // has never written anything — the host refuses the verb without the + // grant, and the driver disables the feed when host.http_patch is + // absent — so rendering it as off, and saving it off, changes nothing. + var armed = wcfg.solar_pv === true && httpCap.allow_write === true; + var ceilingW = Number(wcfg.max_w); + var ceilingValue = Number.isFinite(ceilingW) && ceilingW > 0 ? String(ceilingW) : ""; + slot.innerHTML = '
Solar PV surplus feed' + + '' + + // The host grant rides along with the switch above rather than being a + // second box: same decision, and nobody wants the feed without the + // verb it needs. + '' + + '' + + '' + + '' + + '

' + + 'On the pump itself: set the Local REST API to read/write (installer menu 7.5.15) and turn on the Solar PV input. ' + + 'FTW cannot check either from here — a pump left read-only answers every write with “read only value”.' + + '

' + + '

' + + 'Turning this off clears the feed to 0 W. Before removing FTW altogether, turn the Solar PV input off on the pump as well: ' + + 'once FTW is gone nothing is left to clear a stale value.' + + '

' + + '
'; + + var toggle = slot.querySelector(".drv-write-toggle"); + var grant = slot.querySelector(".drv-write-grant"); + var ceiling = slot.querySelector(".drv-write-max"); + var warning = slot.querySelector(".drv-write-warning"); + if (!toggle || !grant || !ceiling || !warning) return true; + function syncWriteFeed() { + var w = parseFloat(ceiling.value); + var hasCeiling = Number.isFinite(w) && w > 0; + if (toggle.checked && !hasCeiling) { + // The driver refuses a feed with no ceiling too, but it does so in a + // log line an hour later. Refusing where the operator is already + // looking is the same rule, findable. + warning.textContent = "Set a maximum above 0 W first — it bounds every value FTW can send."; + warning.hidden = false; + toggle.checked = false; + } else { + warning.textContent = ""; + warning.hidden = true; + } + grant.checked = toggle.checked; + } + toggle.addEventListener("change", syncWriteFeed); + ceiling.addEventListener("input", syncWriteFeed); + // Run once so a hand-written config that is armed without a valid ceiling + // shows as off with the reason, instead of looking armed and saving back + // a state the driver would refuse anyway. + syncWriteFeed(); + return true; + } + + S.writeFeed = { fill: fillWriteSlot }; + // Some drivers expose more than one fixed register map. Keep these choices // in the UI until the signed driver catalog grows a general config-schema // field. Version gating prevents an older bundled GoodWe driver from being @@ -960,7 +1057,7 @@ '' + '' + '
' + - '' + + '' + '' + '
' + '' + '
' + - '' + + '' + '' + '
' + ''; @@ -1041,6 +1138,12 @@ // Slot for catalog-declared config_secrets (e.g. sonnen Auth-Token). // Filled by the after() pass once /api/drivers/catalog has resolved. html += '
'; + // Slot for a catalog-declared opt-in write path. Empty markup until + // the after() pass sees the driver declare one, which is deliberate: + // an inert hidden fieldset would still have its inputs captured on + // save, writing a write-block into the config of every driver that + // has no write path at all. + html += '
'; if (isCloudDriver) { var cfg = d.config || {}; var hasPw = d.has_password === true; @@ -1086,6 +1189,7 @@ var config = ctx.config; var bodyEl = ctx.bodyEl; var escHtml = ctx.escHtml; + var help = ctx.help; function fmtW(v) { if (!Number.isFinite(v)) return "—"; @@ -1483,6 +1587,14 @@ fs += ''; slot.innerHTML = fs; }); + // Opt-in write paths the installed driver declares. See + // fillWriteSlot: nothing is rendered for a driver that declares none. + bodyEl.querySelectorAll(".drv-write-slot").forEach(function (slot) { + var dIdx = parseInt(slot.getAttribute("data-driver-idx"), 10); + var d = config.drivers[dIdx]; + if (!d || !d.lua) return; + fillWriteSlot(slot, d, byLua[d.lua], {idx: dIdx, help: help, escHtml: escHtml}); + }); bodyEl.querySelectorAll(".drv-disable-pv").forEach(function (lbl) { var lua = lbl.getAttribute("data-drv-lua"); var entry = lua && byLua[lua]; From f1277292e4257ca7c240a6f87057c22e78dc84dc Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 5 Aug 2026 11:42:03 +0200 Subject: [PATCH 2/3] feat(control): forward live solar surplus to armed solar_pv drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .changeset/solar-feed-forwarder.md | 5 + docs/nibe-local.md | 9 ++ go/cmd/ftw/control_state.go | 1 + go/cmd/ftw/main.go | 22 ++++ go/cmd/ftw/solar_feed_send.go | 97 ++++++++++++++++ go/cmd/ftw/solar_feed_send_test.go | 128 +++++++++++++++++++++ go/internal/control/dispatch.go | 7 ++ go/internal/control/solar_feed.go | 118 +++++++++++++++++++ go/internal/control/solar_feed_test.go | 150 +++++++++++++++++++++++++ 9 files changed, 537 insertions(+) create mode 100644 .changeset/solar-feed-forwarder.md create mode 100644 go/cmd/ftw/solar_feed_send.go create mode 100644 go/cmd/ftw/solar_feed_send_test.go create mode 100644 go/internal/control/solar_feed.go create mode 100644 go/internal/control/solar_feed_test.go diff --git a/.changeset/solar-feed-forwarder.md b/.changeset/solar-feed-forwarder.md new file mode 100644 index 00000000..a3e487fb --- /dev/null +++ b/.changeset/solar-feed-forwarder.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Core now actually sends the solar surplus to a driver whose Solar PV feed is armed. Every control tick computes the site's solar-attributable export — the smaller of live PV generation and grid export, after subtracting battery/V2X discharge so stored energy is never advertised as sunshine — and hands it, site-signed, to every driver whose operator enabled the write path (the `solar_pv` action, e.g. the NIBE S-series surplus feed). Dispatch runs behind the existing site-meter freshness gate: stale telemetry stops the feed and the driver's default mode / dead-man switch clears the device register. Standing refusals (pump-side enable still off) log once per transition instead of every tick. diff --git a/docs/nibe-local.md b/docs/nibe-local.md index d109a52a..8d06797c 100644 --- a/docs/nibe-local.md +++ b/docs/nibe-local.md @@ -30,6 +30,15 @@ FTW can send. A driver that declares no write path gets no panel. What the pump needs at its own end — menu 7.5.15 set to read/write, Solar PV input on — cannot be checked from FTW, so the panel states it. +Once armed, core feeds the driver every control tick with the site's +solar-attributable export: the smaller of live PV generation and grid export, +after subtracting battery/V2X discharge (stored energy is never advertised to +the pump as sunshine). The value comes from FTW's own telemetry, so the site +needs a PV source FTW can see and a site meter; a site without PV telemetry +feeds a standing 0. A stale site meter stops dispatch and reverts the driver +to its default mode, which clears the pump-side register — the same clear the +driver's own dead-man switch enforces if commands stop arriving. + **Decommissioning a write-enabled setup.** Every automatic safeguard around the feed (dead-man's switch, default-mode clear, startup orphan sweep) runs inside FTW — none of them can fire once FTW is gone, and the pump's own diff --git a/go/cmd/ftw/control_state.go b/go/cmd/ftw/control_state.go index 4a1c4d3c..52ad86e9 100644 --- a/go/cmd/ftw/control_state.go +++ b/go/cmd/ftw/control_state.go @@ -18,6 +18,7 @@ func newControlStateFromConfig(cfg *config.Config) *control.State { ctrl.MinDispatchIntervalS = cfg.Site.MinDispatchIntervalS ctrl.InverterGroups = inverterGroupsFrom(cfg.Drivers) ctrl.SupportsPVCurtail = supportsPVCurtailFrom(cfg.Drivers) + ctrl.SolarFeedDrivers = solarFeedDriversFrom(cfg.Drivers) ctrl.DriverLimits = driverLimitsFrom(cfg.Drivers, cfg.Batteries) // Per-phase fuse params for the per-phase clamp inside applyFuseGuard // + forceFuseDischarge. Reads l1_a/l2_a/l3_a from the meter driver diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index abad8f5b..2cccdab8 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -794,6 +794,7 @@ func main() { ctrlMu.Lock() ctrl.InverterGroups = inverterGroupsFrom(newCfg.Drivers) ctrl.SupportsPVCurtail = supportsPVCurtailFrom(newCfg.Drivers) + ctrl.SolarFeedDrivers = solarFeedDriversFrom(newCfg.Drivers) ctrl.DriverLimits = driverLimitsFrom(newCfg.Drivers, newCfg.Batteries) // Fuse params + safety margin: previously startup-only. // Hot-reload them so operators can tune the per-phase margin @@ -2383,6 +2384,7 @@ func main() { const evStopLow = 50.0 // W — "now essentially zero" var staleDefaults staleSiteDefaultTracker actuation := newDriverActuationTracker(tel) + solarFeed := newSolarFeedSender() for { select { case <-sigc: @@ -2708,6 +2710,26 @@ func main() { sendDriverCommand(ctx, reg, "pv curtail send", c.Driver, payload, driverCmdTimeout) } + // ---- Solar-surplus feed dispatch ---- + // Per-tick hint to drivers whose operator armed a `solar_pv` + // write path (NIBE Solar PV surplus feed, #537): the live + // solar-attributable export, site-signed. Sent every tick — + // repetition is what feeds the driver's dead-man switch; the + // driver deadbands and rate-limits the actual device writes. + // Runs only on freshness-allowed ticks (the stale path above + // `continue`s), and a stale entry already walks every driver + // to its default mode, which clears the device-side feed. + ctrlMu.Lock() + feedTargets := control.ComputeSolarFeed(ctrl, tel) + ctrlMu.Unlock() + for _, f := range feedTargets { + if observeOnlySnap[f.Driver] { + continue + } + payload, _ := json.Marshal(map[string]any{"action": "solar_pv", "power_w": f.PowerW}) + solarFeed.send(ctx, reg, f.Driver, payload, driverCmdTimeout) + } + // LP dispatch ran at the top of this tick — see the // "EV dispatch first" block above. diff --git a/go/cmd/ftw/solar_feed_send.go b/go/cmd/ftw/solar_feed_send.go new file mode 100644 index 00000000..9adab782 --- /dev/null +++ b/go/cmd/ftw/solar_feed_send.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "log/slog" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// solarFeedDriversFrom builds control.State.SolarFeedDrivers from config: +// the drivers whose operator armed the opt-in `solar_pv` write path. The +// gates mirror what the driver itself enforces (see the NIBE driver's +// write_cfg validation), so core never sends a payload the driver would +// refuse on configuration grounds alone: +// +// - capabilities.http.allow_write — the host grant for http_patch; +// - config.write.solar_pv: true — the driver-side opt-in; +// - config.write.max_w > 0 — the clamp ceiling; the driver +// refuses to arm without one, so a missing/zero ceiling means the +// operator never finished arming the feed. +// +// The Settings UI sets the first two together and requires the third, +// so a UI-armed driver always passes. Hand-written configs that arm +// only half the gates get no commands — same net effect as the driver +// refusing, minus a per-tick refusal in the log. +func solarFeedDriversFrom(drivers []config.Driver) map[string]bool { + out := map[string]bool{} + for _, d := range drivers { + if d.Disabled { + continue + } + if d.Capabilities.HTTP == nil || !d.Capabilities.HTTP.AllowWrite { + continue + } + w, _ := d.Config["write"].(map[string]any) + if w == nil { + continue + } + if enabled, _ := w["solar_pv"].(bool); !enabled { + continue + } + if configNumber(w["max_w"]) <= 0 { + continue + } + out[d.Name] = true + } + return out +} + +// configNumber reads a numeric value out of a driver's opaque config +// map. yaml.v3 decodes numbers as int (or int64 past 32 bits), the +// JSON round-trip through the settings UI as float64. +func configNumber(v any) float64 { + switch n := v.(type) { + case int: + return float64(n) + case int64: + return float64(n) + case float64: + return n + } + return 0 +} + +// solarFeedSender sends per-tick `solar_pv` hints with edge-triggered +// logging. Unlike battery dispatch, a refused solar feed can be a +// steady state measured in days — the pump-side enable register still +// off, the pump not detected yet — and the hint repeats every control +// tick precisely to feed the driver's dead-man switch. Logging every +// refusal would print the same line every few seconds for as long as +// the operator leaves the pump half-configured, so only transitions +// are logged: first refusal, a changed refusal, and recovery. +type solarFeedSender struct { + lastErr map[string]string +} + +func newSolarFeedSender() *solarFeedSender { + return &solarFeedSender{lastErr: map[string]string{}} +} + +func (s *solarFeedSender) send(ctx context.Context, reg driverCommandSender, name string, payload []byte, timeout time.Duration) { + cmdCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + err := reg.Send(cmdCtx, name, payload) + if err == nil { + if s.lastErr[name] != "" { + slog.Info("solar feed send recovered", "name", name) + delete(s.lastErr, name) + } + return + } + if msg := err.Error(); s.lastErr[name] != msg { + slog.Warn("solar feed send", "name", name, "err", err) + s.lastErr[name] = msg + } +} diff --git a/go/cmd/ftw/solar_feed_send_test.go b/go/cmd/ftw/solar_feed_send_test.go new file mode 100644 index 00000000..6f0da3e3 --- /dev/null +++ b/go/cmd/ftw/solar_feed_send_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +func armedNibeDriver() config.Driver { + return config.Driver{ + Name: "nibe", + Lua: "drivers/nibe_local.lua", + Capabilities: config.Capabilities{ + HTTP: &config.HTTPCapability{ + AllowedHosts: []string{"192.168.1.20:8443"}, + AllowWrite: true, + }, + }, + Config: map[string]any{ + "write": map[string]any{"solar_pv": true, "max_w": 9000}, + }, + } +} + +func TestSolarFeedDriversFrom_AllGatesArmed(t *testing.T) { + got := solarFeedDriversFrom([]config.Driver{armedNibeDriver()}) + if !got["nibe"] || len(got) != 1 { + t.Errorf("want {nibe:true}; got %v", got) + } +} + +func TestSolarFeedDriversFrom_HalfArmedConfigsGetNothing(t *testing.T) { + cases := []struct { + name string + mutate func(*config.Driver) + }{ + {"disabled driver", func(d *config.Driver) { d.Disabled = true }}, + {"no http capability", func(d *config.Driver) { d.Capabilities.HTTP = nil }}, + {"allow_write off", func(d *config.Driver) { d.Capabilities.HTTP.AllowWrite = false }}, + {"no write block", func(d *config.Driver) { delete(d.Config, "write") }}, + {"solar_pv off", func(d *config.Driver) { + d.Config["write"].(map[string]any)["solar_pv"] = false + }}, + {"max_w missing", func(d *config.Driver) { + delete(d.Config["write"].(map[string]any), "max_w") + }}, + {"max_w zero", func(d *config.Driver) { + d.Config["write"].(map[string]any)["max_w"] = 0 + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := armedNibeDriver() + tc.mutate(&d) + if got := solarFeedDriversFrom([]config.Driver{d}); len(got) != 0 { + t.Errorf("want empty set; got %v", got) + } + }) + } +} + +// max_w arrives as int from yaml.v3 and float64 from the settings UI's +// JSON round-trip; both must arm the feed. +func TestSolarFeedDriversFrom_NumericTypes(t *testing.T) { + for _, maxW := range []any{int(9000), int64(9000), float64(9000)} { + d := armedNibeDriver() + d.Config["write"].(map[string]any)["max_w"] = maxW + if got := solarFeedDriversFrom([]config.Driver{d}); !got["nibe"] { + t.Errorf("max_w %T(%v): want armed; got %v", maxW, maxW, got) + } + } +} + +func TestSolarFeedSender_LogsRefusalOnceUntilItChanges(t *testing.T) { + buf := captureWarnings(t) + sender := &stubSender{handler: func(ctx context.Context, name string) error { + return errors.New("solar_pv: register 2107 disabled") + }} + feed := newSolarFeedSender() + payload := []byte(`{"action":"solar_pv","power_w":-500}`) + + for i := 0; i < 3; i++ { + feed.send(context.Background(), sender, "nibe", payload, time.Second) + } + if got := strings.Count(buf.String(), "solar feed send"); got != 1 { + t.Errorf("want 1 warning for a standing refusal, got %d:\n%s", got, buf.String()) + } + + sender.handler = func(ctx context.Context, name string) error { + return errors.New("solar_pv: pump not detected yet") + } + feed.send(context.Background(), sender, "nibe", payload, time.Second) + if got := strings.Count(buf.String(), "solar feed send"); got != 2 { + t.Errorf("want a second warning when the refusal changes, got %d:\n%s", got, buf.String()) + } +} + +func TestSolarFeedSender_RecoveryClearsTheLatch(t *testing.T) { + buf := captureWarnings(t) + fail := true + sender := &stubSender{handler: func(ctx context.Context, name string) error { + if fail { + return errors.New("solar_pv: register 2107 disabled") + } + return nil + }} + feed := newSolarFeedSender() + payload := []byte(`{"action":"solar_pv","power_w":-500}`) + + feed.send(context.Background(), sender, "nibe", payload, time.Second) + fail = false + feed.send(context.Background(), sender, "nibe", payload, time.Second) + fail = true + feed.send(context.Background(), sender, "nibe", payload, time.Second) + + // Refusal → recovery → same refusal again must log the refusal twice: + // the latch resets on success so a returning fault is not silent. + if got := strings.Count(buf.String(), "solar feed send"); got != 2 { + t.Errorf("want 2 warnings around a recovery, got %d:\n%s", got, buf.String()) + } + if calls := sender.recorded(); len(calls) != 3 || calls[0].payload != string(payload) { + t.Errorf("unexpected recorded calls: %+v", calls) + } +} diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 7ae7a2b5..dfb8713c 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -529,6 +529,13 @@ type State struct { // hot-swappable via the config-reload watcher. SupportsPVCurtail map[string]bool + // SolarFeedDrivers flags drivers whose operator armed an opt-in + // `solar_pv` write path (e.g. the NIBE S-series Solar PV surplus + // feed). ComputeSolarFeed sends only to flagged drivers. Populated + // from the per-driver write gates by main.go's solarFeedDriversFrom; + // hot-swappable via the config-reload watcher like SupportsPVCurtail. + SolarFeedDrivers map[string]bool + // DCLinkProtectionEnabled opts into a live-state curtail trigger // that fires INDEPENDENTLY of the planner directive when the // inverter's DC link is most exposed to a load-step fault: SoC diff --git a/go/internal/control/solar_feed.go b/go/internal/control/solar_feed.go new file mode 100644 index 00000000..20a18ccc --- /dev/null +++ b/go/internal/control/solar_feed.go @@ -0,0 +1,118 @@ +package control + +import ( + "sort" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// SolarFeedTarget is one per-tick solar-surplus hint for a driver whose +// device consumes an "available solar power" input (e.g. the NIBE +// S-series Solar PV registers, driver action `solar_pv`). PowerW is +// site-signed like every power in core: negative = power leaving the +// site = exportable surplus; 0 = no surplus right now. The driver owns +// the device-side safety (clamp to its configured ceiling, deadband, +// rate limit, dead-man's switch), so the hint is sent every tick — a +// value repeated unchanged is what keeps the driver's dead-man fed. +type SolarFeedTarget struct { + Driver string `json:"driver"` + PowerW float64 `json:"power_w"` +} + +// ComputeSolarFeed returns one SolarFeedTarget per driver in +// state.SolarFeedDrivers for this dispatch tick. Drivers not in the set +// are silently skipped — only a driver whose operator armed its write +// path (config write gates, see main.go's solarFeedDriversFrom) may +// receive a `solar_pv` payload. +// +// Every listed driver gets the same site-wide value: solar surplus is a +// property of the site boundary, not of the consuming device. The caller +// dispatches behind the site-meter freshness gate, and a stale gate +// already reverts every driver to its default mode (which clears the +// device-side feed), so this function does not re-check freshness. +func ComputeSolarFeed(state *State, store *telemetry.Store) []SolarFeedTarget { + if state == nil || store == nil { + return nil + } + names := make([]string, 0, len(state.SolarFeedDrivers)) + for name, on := range state.SolarFeedDrivers { + if on { + names = append(names, name) + } + } + if len(names) == 0 { + return nil + } + sort.Strings(names) + + surplus := solarSurplusW(state, store) + out := make([]SolarFeedTarget, 0, len(names)) + for _, name := range names { + out = append(out, SolarFeedTarget{Driver: name, PowerW: -surplus}) + } + return out +} + +// solarSurplusW is the watts of live grid export attributable to solar: +// +// min(online PV generation, export − battery/V2X discharge), floored at 0. +// +// The two clamps each guard a real misattribution: +// +// - capping at PV generation keeps a site that exports without sun +// (battery arbitrage, V2X) from advertising those watts as free +// solar the device would then burn against the owner's arbitrage; +// - subtracting battery/V2X discharge first removes the storage +// contribution from mixed export, so 4 kW export during a 3 kW +// battery discharge reports only the 1 kW the sun is covering. +// +// Only online drivers count. Telemetry store signs: sources are negative +// (PV generating, meter exporting, battery/V2X discharging), so +// generation and discharge are read as -SmoothedW. Missing telemetry +// degrades toward 0 — the safe direction for a hint whose worst case is +// a device soaking surplus that is not there. +func solarSurplusW(state *State, store *telemetry.Store) float64 { + if state.SiteMeterDriver == "" { + return 0 + } + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + if meter == nil { + return 0 + } + exportW := -meter.SmoothedW + if exportW <= 0 { + return 0 + } + + pvGenW := sumOnlineSourcedW(store, telemetry.DerPV) + if pvGenW <= 0 { + return 0 + } + + exportW -= sumOnlineSourcedW(store, telemetry.DerBattery) + exportW -= sumOnlineSourcedW(store, telemetry.DerV2X) + if exportW <= 0 { + return 0 + } + if pvGenW < exportW { + return pvGenW + } + return exportW +} + +// sumOnlineSourcedW sums the power currently sourced (negative +// SmoothedW) by online drivers of one DER type, as a positive number. +// Consuming readings (charging battery, curtailed-to-zero PV) add 0. +func sumOnlineSourcedW(store *telemetry.Store, typ telemetry.DerType) float64 { + var sum float64 + for _, r := range store.ReadingsByType(typ) { + h := store.DriverHealth(r.Driver) + if h == nil || !h.IsOnline() { + continue + } + if r.SmoothedW < 0 { + sum += -r.SmoothedW + } + } + return sum +} diff --git a/go/internal/control/solar_feed_test.go b/go/internal/control/solar_feed_test.go new file mode 100644 index 00000000..0d882938 --- /dev/null +++ b/go/internal/control/solar_feed_test.go @@ -0,0 +1,150 @@ +package control + +import ( + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// emitV2X pushes a bidirectional-charger reading with an explicit SoC +// (V2X readings without one are rejected at validation). +func emitV2X(t *testing.T, s *telemetry.Store, driver string, w, soc float64) { + t.Helper() + s.DriverHealthMut(driver).RecordSuccess() + s.Update(driver, telemetry.DerV2X, w, &soc, nil) +} + +func solarFeedState(drivers ...string) *State { + st := NewState(0, 100, "meter") + st.SolarFeedDrivers = map[string]bool{} + for _, d := range drivers { + st.SolarFeedDrivers[d] = true + } + return st +} + +func TestComputeSolarFeed_NoArmedDrivers_ReturnsNil(t *testing.T) { + st := NewState(0, 100, "meter") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -3000) + emitPV(t, store, "pv", -5000) + if got := ComputeSolarFeed(st, store); got != nil { + t.Errorf("expected nil with no armed drivers; got %+v", got) + } +} + +func TestComputeSolarFeed_ExportSmallerThanPV_SendsExport(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -3000) + emitPV(t, store, "pv", -5000) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].Driver != "nibe" || got[0].PowerW != -3000 { + t.Errorf("want [{nibe -3000}]; got %+v", got) + } +} + +func TestComputeSolarFeed_ExportLargerThanPV_CapsAtPVGeneration(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -5000) + emitPV(t, store, "pv", -3000) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != -3000 { + t.Errorf("want PowerW -3000 (PV-capped); got %+v", got) + } +} + +func TestComputeSolarFeed_Importing_SendsZero(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", 500) + emitPV(t, store, "pv", -5000) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != 0 { + t.Errorf("want PowerW 0 while importing; got %+v", got) + } +} + +func TestComputeSolarFeed_BatteryDischargeIsNotSolar(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -4000) + emitPV(t, store, "pv", -5000) + emitBattery(t, store, "bat", -3000, 0.5) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != -1000 { + t.Errorf("want PowerW -1000 (export minus battery discharge); got %+v", got) + } +} + +func TestComputeSolarFeed_ChargingBatteryDoesNotInflateSurplus(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -1000) + emitPV(t, store, "pv", -5000) + emitBattery(t, store, "bat", 2000, 0.5) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != -1000 { + t.Errorf("want PowerW -1000 (charge draw must not add surplus); got %+v", got) + } +} + +func TestComputeSolarFeed_V2XDischargeIsNotSolar(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -2000) + emitPV(t, store, "pv", -5000) + emitV2X(t, store, "car", -1500, 0.7) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != -500 { + t.Errorf("want PowerW -500 (export minus V2X discharge); got %+v", got) + } +} + +func TestComputeSolarFeed_NoPVTelemetry_SendsZero(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -2000) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != 0 { + t.Errorf("want PowerW 0 without PV telemetry; got %+v", got) + } +} + +func TestComputeSolarFeed_OfflinePVDriver_SendsZero(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -2000) + // PV reading present but its driver never recorded a success — the + // generation of an offline driver must not count as live surplus. + store.Update("pv", telemetry.DerPV, -5000, nil, nil) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != 0 { + t.Errorf("want PowerW 0 with only offline PV; got %+v", got) + } +} + +func TestComputeSolarFeed_MissingMeter_SendsZero(t *testing.T) { + st := solarFeedState("nibe") + store := telemetry.NewStore() + emitPV(t, store, "pv", -5000) + got := ComputeSolarFeed(st, store) + if len(got) != 1 || got[0].PowerW != 0 { + t.Errorf("want PowerW 0 without a site-meter reading; got %+v", got) + } +} + +func TestComputeSolarFeed_MultipleDrivers_SameValueSorted(t *testing.T) { + st := solarFeedState("zeta", "alpha") + store := telemetry.NewStore() + emitMeter(t, store, "meter", -3000) + emitPV(t, store, "pv", -5000) + got := ComputeSolarFeed(st, store) + if len(got) != 2 || got[0].Driver != "alpha" || got[1].Driver != "zeta" { + t.Fatalf("want sorted [alpha zeta]; got %+v", got) + } + if got[0].PowerW != -3000 || got[1].PowerW != -3000 { + t.Errorf("want the same site-wide value for both; got %+v", got) + } +} From 4c1aa8ba5e09fe700137f61bb0a66f65f81cccbf Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Wed, 5 Aug 2026 12:27:06 +0200 Subject: [PATCH 3/3] docs(nibe): name the pump-side screens the Solar PV feed needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- docs/nibe-local.md | 25 +++++++++++++++++++++---- web/settings/tabs/devices.js | 5 +++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/nibe-local.md b/docs/nibe-local.md index 8d06797c..3f3a2c4b 100644 --- a/docs/nibe-local.md +++ b/docs/nibe-local.md @@ -27,8 +27,24 @@ panel on it: one switch and the maximum surplus to report. The switch sets both gates together, because either alone does nothing, and the feed stays off until the maximum is above 0 — that ceiling is what bounds every value FTW can send. A driver that declares no write path gets no panel. What the -pump needs at its own end — menu 7.5.15 set to read/write, Solar PV input on -— cannot be checked from FTW, so the panel states it. +pump needs at its own end cannot be checked from FTW, so the panel states it: + +1. **Installer menu 7.5.15** — Local REST API set to **read/write**. +2. **Menu 4.2.2 "solar electricity"** — turn on the external Solar PV + source, named **"Modbus TCP/IP Ext. (Solar PV)"** on the pump (register + 2107; names read live off an S735). FTW deliberately never writes this + register — turning the feature on is the owner's consent, given at the + device, and the driver holds every non-zero value back until it reads 1. + Menu 4.2.2 is a plus function under menu 4.2: if it is not visible, add + the solar-electricity function from installer menu 7.2.1 (add/remove + accessories). The same menu holds what the pump *does* with the value — + "include own consumption" (register 2108) and the "(Solar PV)" offsets + for heating, cooling and pool. + +The tell that the pump is listening: the read-only point *Total average +power (Solar PV)* (register 2178, emitted as +`hp_total_average_power_solar_pv`) starts tracking the fed value instead of +sitting at 0. Once armed, core feeds the driver every control tick with the site's solar-attributable export: the smaller of live PV generation and grid export, @@ -44,8 +60,9 @@ the feed (dead-man's switch, default-mode clear, startup orphan sweep) runs inside FTW — none of them can fire once FTW is gone, and the pump's own timeout for a silently stopped feed is undocumented. Before uninstalling FTW or permanently disabling the feed, turn the **Solar PV input (2107) off on -the pump** — or set the Local REST API back to **read-only (menu 7.5.15)** -— so no stale surplus value can stand with nobody left to clear it. +the pump** (menu 4.2.2) — or set the Local REST API back to **read-only +(menu 7.5.15)** — so no stale surplus value can stand with nobody left to +clear it. ## Why the local API (vs. the cloud or raw Modbus) diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index ef6b63d7..f77a7076 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -75,11 +75,12 @@ 'placeholder="e.g. 9000">' + '' + '

' + - 'On the pump itself: set the Local REST API to read/write (installer menu 7.5.15) and turn on the Solar PV input. ' + + 'On the pump itself: set the Local REST API to read/write (installer menu 7.5.15) and turn on the external Solar PV source — ' + + '“Modbus TCP/IP Ext. (Solar PV)”, register 2107, in the pump’s solar electricity settings (menu 4.2.2; a plus function, added from installer menu 7.2.1 if the menu is missing). ' + 'FTW cannot check either from here — a pump left read-only answers every write with “read only value”.' + '

' + '

' + - 'Turning this off clears the feed to 0 W. Before removing FTW altogether, turn the Solar PV input off on the pump as well: ' + + 'Turning this off clears the feed to 0 W. Before removing FTW altogether, turn the Solar PV input off on the pump as well (menu 4.2.2): ' + 'once FTW is gone nothing is left to clear a stale value.' + '

' + '';