Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solar-feed-forwarder.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions .changeset/solar-pv-write-switch.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 37 additions & 2 deletions docs/nibe-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,48 @@ 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 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,
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
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)

Expand Down
8 changes: 8 additions & 0 deletions docs/writing-a-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ not enough, it also needs `capabilities.http.allow_write`. It refuses to follow
redirects, because Go re-issues a redirected `PATCH` as a body-less GET and a
device write that never landed would otherwise report success.

A driver with an opt-in write path names it in its `DRIVER` block —
`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
`capabilities.http.allow_write` is still a separate grant.

## Capability grants

A YAML driver entry grants only what the file needs. The resource keys are
Expand Down
1 change: 1 addition & 0 deletions go/cmd/ftw/control_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go/cmd/ftw/driver_failure_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ import (
// `curtail_disable` release, which does not. pv_curtail_dispatch.go.
// - The loadpoint sends the periodic `ev_set_current`, which counts, and
// four other things that do not. See loadpoint.DispatchOutcomeFunc.
// - The solar feed sends the `solar_pv` hint, which does not count: a
// refusal there says the owner has not opened the device-side gate
// (the pump's Solar PV enable), not that core cannot put power where
// it asked — no power was asked for. solar_feed_send.go.

const (
// driverRefusalLimit is how many refused dispatch commands in a row
Expand Down
22 changes: 22 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,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
Expand Down Expand Up @@ -2455,6 +2456,7 @@ func main() {
const evStopHigh = 100.0 // W — "was actually drawing"
const evStopLow = 50.0 // W — "now essentially zero"
var staleDefaults staleSiteDefaultTracker
solarFeed := newSolarFeedSender()
for {
select {
case <-sigc:
Expand Down Expand Up @@ -2769,6 +2771,26 @@ func main() {
// release is not. See pv_curtail_dispatch.go.
dispatchPVCurtail(ctx, reg, actuation, curtailTargets, driverCmdTimeout, tickNow)

// ---- 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.

Expand Down
104 changes: 104 additions & 0 deletions go/cmd/ftw/solar_feed_send.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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.
//
// Deliberately not routed through driverActuationTracker: its question
// — does refusing this say core cannot put power where it asked? — is
// answered no for a hint. No power was asked for, the driver's health
// is untouched by declining to relay information the device owner has
// not consented to receive, and booking the refusal would paint a
// correctly-configured read-only pump as a device fault.
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
}
}
Loading