Skip to content

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

Description

@HuggeK

Follow-up to #530 (read-only NIBE local REST driver), #532 (heat-pump dashboard UI) and srcfl/hugin-drivers#7 (driver packaging). The shipped driver is observe-only by design — driver_command returns false and the header promises "NO control". This issue plans the opt-in write path.

Goal

Feed the pump's native Solar PV feature: periodically write live PV production into the Local REST API so the pump raises its heating / hot-water setpoints when there is surplus solar — the role NIBE's own Modbus accessory plays today, with forty-two-watts acting as that accessory.

This is control-by-hint, not direct actuation: the pump's own firmware decides what to do with the number. Misbehaviour degrades to "pump believes a wrong solar value", never to unsafe operation — which is what makes a writable v1 tractable.

Target points (seen in the S735 register map; writable flag to be confirmed in phase 0)

Point Title Role
2107 Modbus TCP/IP Ext. (Solar PV) master enable — "an external source reports PV over TCP/IP"
2108 Include own consumption (Solar PV) semantic switch: does the written value already account for house load?
2109 Available power (Solar PV) the periodically written power value
— Offset heating/cooling/pool (Solar PV) user-tunable aggressiveness — stays user-owned, we don't write it
What these registers mean (semantics)

The S-series has a built-in "Solar PV" input designed for NIBE's Modbus TCP/IP accessory. When 2107 is enabled, the pump expects an external box to keep 2109 updated with the currently available solar power. 2108 tells the pump how to interpret that number:

  • 2108 off → 2109 is gross PV production; the pump subtracts its own draw when judging surplus.
  • 2108 on → 2109 is already net available power (production minus household consumption); the pump treats it as directly usable.

forty-two-watts knows both quantities from site telemetry (PV production + site meter), so either mode is implementable — the config picks one and the forwarder computes the matching value. When surplus exists, the pump applies the user-set Offset heating/cooling/pool setpoint shifts to soak it up.

Design sketch

Data flow — follows the loadpoint precedent (loadpoint.Controller → SenderFunc → registry.SendCommand → driver_command):

main dispatch cycle
  └─ solar-feed forwarder (clamp + stale guard)
       └─ SendCommand(nibe, {action:"solar_pv", power_w:…})
            └─ nibe_local.lua driver_command
                 └─ PATCH /api/v1/devices/{id}/points  {"2109": value}
  • Sign convention: PV is negative site-convention W; conversion to the pump's positive-W value happens in the driver, as always (docs/site-convention.md).
  • The forwarder is a small piece of the main loop, not a new planner: it forwards a telemetry-derived number at poll cadence. No schedule, no SoC — so it should not be a loadpoint; just a dedicated forwarder alongside the dispatch cycle.

Host API: add host.http_patch(url, body, headers) (or a generic http_request(method, …)), gated separately from read access — e.g. capabilities.http.allow_write: true. Without the grant, PATCH returns an error string exactly like ungranted MQTT/Modbus does today, so read-only remains the default even for drivers that already have http.

Opt-in — all three must hold before a single write happens:

  1. capabilities.http.allow_write: true in config.yaml (host-enforced)
  2. driver-level config.write.solar_pv: true
  3. pump-side: 2107 enabled by the owner

Safety (per docs/clamping.md, every clamp answers a quantifiable risk):

  • Clamp the written value to [0, PV nameplate] — risk: a sign bug or telemetry spike telling the pump there are 100 kW of surplus.
  • Stale guard: if site-meter or PV telemetry is stale, write 0 once, then stop — mirrors the existing stale-meter dispatch guard. Risk: pump chasing yesterday's sunshine.
  • Watchdog / DefaultMode → stop writing. Absence of writes must be the safe state; phase 0 confirms how the pump times out a silent feed.
  • Write cadence: only on change beyond a deadband, at most once per poll (60 s). Phase 0 confirms 2109 is RAM-backed, not EEPROM (flash wear).

Phases

Phase 0 — groundwork (no writes yet)

  • Enumerate all writable: true points from the live catalog (the metadata already carries the flag; one filtered bulk GET)
  • Confirm write mechanics from the pump's OpenAPI self-description at https://<ip>:8443/ — PATCH body shape, whether any pump-side mode must be enabled first, rate limits
  • Confirm what the pump does when writes stop (revert-to-no-solar timeout?) — this defines the failure mode
  • Decide default semantics: gross production vs. net surplus (2108) — leaning net surplus, since 42W computes it better than the pump can guess

Phase 1 — Solar PV feed (core of this issue)

  • host.http_patch + http.allow_write capability + a capability-denied test (pattern: TestLuaHTTPCapabilityNotGranted)
  • Solar-feed forwarder in the main loop with clamp + stale guard + deadband
  • driver_command in nibe_local.lua implementing the solar_pv action (and updating the "observe-only" header contract)
  • Config schema + docs (configuration.md, safety.md, driver docs)
  • UI: show feed status on the heat-pump card — last written value + timestamp

Phase 2 — electric add-heat (immersion heater) power limit

  • Identify the "max electrical addition" point id from the phase-0 writable catalog
  • Wire it to the fuse guard / capacity-tariff logic (the per-phase fuse clamp already exists in loadpoint) so resistive heat can be capped when the mains fuse or the power tariff is under pressure

Phase 3 — parked ideas (split into their own issues when reached)

  • Temporary lux (hot-water boost) to dump large surplus into the tank
  • Blocking add-heat during price spikes — needs care: the pump's own "smart price adaption" may already be optimising on price, and two optimisers fighting is worse than one

Test site

Writes need a site where trial-and-error is acceptable: the S735 the read-only driver was verified on is the candidate (same pump, creds and cert pin already provisioned). Everything defaults off for everyone else.

Activity

  1. HuggeK commented on Jul 29, 2026

    @HuggeK
    ContributorAuthor

    Written by Claude (Opus 4.8) on Hugo's behalf — phase-0 findings plus the first implementation slice.

    Phase 0 of this issue is now answered, and the driver-side write path + host prerequisite are implemented as two draft PRs:

    The driver is rebased onto current device-drivers main. Editing it drops its byte-identity exemption from baselines/ftw, so the full catalog convention suite now applies — it passes (including test_no_undefined_locals), and make check's validators (manifest sync, host-API profile, history, baseline, sandbox) are green. The two PRs are independent: the driver detects a core without host.http_patch and stays read-only, so it is safe to land ahead of the core change.

    Phase-0 findings

    Write mechanics — confirmed. PATCH /api/v1/devices/{id}/points with a JSON array of datavalue objects: [{"type":"datavalue","variableId":N,"integerValue":<raw pre-divisor int>,"stringValue":""}]. The variableId is the Local-API id, not the Modbus register — but every point's metadata carries modbusRegisterID, so the driver resolves 2107/2109 → variableIds from the bulk GET it already does, no hard-coded ids.

    The API's most dangerous habit: the pump rejects writes inside an HTTP 200. The per-point result string (modified / error: read only value / error: no such param) is the real verdict. The driver parses it and surfaces a read-only pump as an actionable error naming the installer menu.

    Correction to our docs: aidMode is not the write gate. Aid mode is the pump's compressor-off fault-recovery state. The actual write permission is the installer's read-only vs read/write choice for the Local REST API (menu 7.5.15). Our driver header and docs/nibe-local.md said "read-only mode (aidMode=off)" — both PRs fix that.

    What happens when writes stop — undocumented. No public source states how (or whether) the pump times out a silent 2109 feed. So the driver assumes the worst and owns the failure mode itself: a dead-man's switch writes 0 when commands stop (write.ttl_s, default 300 s), driver_default_mode clears the feed on watchdog/stale-site-meter/stop, and a startup sweep clears a non-zero feed left by a crashed run. Confirming the pump's actual timeout stays on the HIL checklist for the S735.

    Writable-register risk classification (why 2107/2109 is the right first write, and what stays off-limits)

    Registers are S-series Modbus ids (the public register export mirrors the pump's own menu 7.5.9 export; Local-API variableIds resolved at runtime).

    Class Registers Why
    Safe / self-limiting 2109 Available power (+2107 enable, owner-side) Control-by-hint: pump firmware decides; effect bounded by owner-tuned offsets (max +10 curve). Stuck value ⇒ wasted comfort, never unsafe. Chosen for phase 1.
    Safe / one-shot 697 "More hot water" boost (countdown in 225) Bounded single boost. Parked (phase 3).
    Guarded / bounded 30 heating offset (±10) · 206 room setpoint (5–30 °C) · 51/55 external adjustment (needs AUX activation) · 3032+6008 SG-Ready via API (fw 4.7.5+) · 6007/6012 power limitation Persistent until rewritten: a stuck write over/under-heats or caps capacity in a cold snap. SG-Ready state 0 (Blocking) is effectively a persistent compressor block — treat as dangerous.
    Dangerous / persistent 11 degree minutes (forced compressor starts/stops ⇒ short-cycling wear) · 2743 operating mode (add-heat-only = 3–5× cost) · 56–67 DHW temps & periodic hot water (legionella) · 26/39–47 heat curve · 2740–2742 AUX blocks (frozen pipes if stuck) Real hardware/health risk from a stuck or malicious write. Not for an optimizer.
    Forbidden 22 alarm reset + DELETE /notifications · POST /aidmode · 2755 inverter fault reset · 137/139 defrost · 5217–5225 external sensor overrides Masks faults or feeds the pump fake sensor data. Never.

    Notably 5217–5225 let an external system replace physical sensor readings — a spoofed tank temp can stop DHW charging (legionella) and a spoofed outdoor temp corrupts the whole heat curve. The driver hard-codes nothing outside 2107/2109 and rejects every other action.

    What the implementation enforces (per this issue's design sketch)

    All three opt-ins required before a single write: capabilities.http.allow_write: true (host-enforced — PATCH never leaves the core without it), driver write.solar_pv: true with a mandatory write.max_w clamp ceiling (refuses to arm without it), and pump-side 2107 enabled by the owner (checked from the live register map each poll; refused with a pointed error while off). Value clamped to [0, max_w] (and below the u16 sentinel), deadband (default 50 W) + at-most-one-write-per-interval, sign conversion (site-negative → pump-positive) inside the driver, and a hp_solar_pv_feed_w metric so the feed is observable in the TS DB alongside the pump's own 2109 readback.

    Still open on this issue

    • Solar-feed forwarder in the core dispatch loop (clamp + stale-telemetry guard + deadband at the source, per the sketch — the driver-side guards are defense in depth, not a replacement)
    • UI: feed status on the heat-pump card
    • HIL on the S735: confirm the pump-side silent-feed timeout, confirm 2109 is RAM-backed, decide 2108 semantics (gross vs net — still leaning net)
    • Phase 2 (add-heat power cap) and phase 3 (hot-water boost) unchanged
  2. HuggeK commented on Jul 29, 2026

    @HuggeK
    ContributorAuthor

    Status update — implemented, and verified as far as available hardware allows

    Posted by Claude (Opus 4.8) on Hugo's behalf.

    Both draft PRs are up and green on everything that can run without live solar hardware:

    • Driver → srcfl/device-drivers#46 — nibe_local 1.1.0 → 1.2.0, the Solar PV surplus feed. Rebased onto device-drivers main; passes the full catalog convention suite (it lost its baselines/ftw byte-identity exemption the moment it was edited).
    • Core → srcfl/ftw#716 — host.http_patch gated by capabilities.http.allow_write; unit tests pin both gates, the wire format, and the redirect refusal.

    What the live system can and cannot confirm

    Hugo's production gateway (FTW v1.13.4) runs the NIBE Local REST driver against a real S-series pump, and it is healthy:

    • status: ok, 0 consecutive errors, 14 358 successful ticks, last success seconds before this write, no device fault.

    So the read path is field-proven on real hardware. But that site has no PV and no battery (pv_w: 0, PV today 0 Wh), and v1.13.4 is read-only regardless — so the one thing that still needs a real device is precisely the new bit: an actual 2109 write, and how the pump behaves when the feed stops.

    No write was attempted against the live pump — the write path isn't deployed there, there's no surplus to feed, and it's someone's heating.

    🙋 Looking for a tester: S-series heat pump + PV

    This is the gap we can't close ourselves — Hugo has the pump but not the solar. If you run a NIBE S-series (S735/S1255/S1155/S320/…) alongside a PV array, we'd love your help validating the Solar PV feed on real hardware. Specifically:

    • confirm register 2107 ("Modbus TCP/IP Ext. / Solar PV") can be enabled on your pump and 2109 ("Available power") accepts a write over the Local REST API (installer menu 7.5.15 set to read/write);
    • confirm the pump actually soaks surplus into heating/hot-water via its offsets when fed a non-zero 2109;
    • the key unknown: how long the pump holds a stale 2109 value after the feed stops (the driver clears it via a dead-man's switch because this timeout is undocumented — we'd like to measure the real number);
    • whether 2109 is RAM-backed (survives a reboot or not) and the intended 2108 ("include own consumption") semantics.

    It runs read-only until you opt in three times over (host allow_write, driver write.solar_pv: true + write.max_w, and the pump-side 2107), so trying it is low-risk. Reply here or ping Hugo if you can help.

  3. HuggeK commented on Aug 4, 2026

    @HuggeK
    ContributorAuthor

    HIL results: the write path ran against a real S-series pump

    The Solar PV write mechanism has now been validated end-to-end on real
    hardware — an S-series pump on its Local REST API, on the same site that
    produced the earlier read-path findings. The owner switched the pump's API to
    read/write (installer menu 7.5.15) for this test. Every safety mechanism in
    the driver fired as designed, on the first attempt.

    What was proven, in order:

    1. The pump accepts the write. One PATCH of 40 W to the available-power
      point (register 2109) came back modified — judged in the response body,
      which is where this pump reports refusals. Menu 7.5.15 read/write is
      therefore confirmed as the real pump-side gate.
    2. The full FTW chain works. The write went through the deployed core's
      capabilities.http.allow_write gate, the per-driver host allowlist, the
      pinned self-signed TLS cert, and the host.http_patch verb from feat(drivers): host.http_patch write verb for device REST writes (#537) #716 —
      no step needed adjustment.
    3. The value verifiably lands. The driver's own next poll read register
      2109 back through the bulk points GET: the telemetry series shows
      0 → 40 → 0, exactly bracketing the write and the later clear.
    4. The dead-man's switch fires. With write.ttl_s: 120 and no further
      commands, the driver cleared the feed autonomously at +124 s
      (feed stale — dead-man's switch). Observed twice.
    5. The orphan sweep recovers a crash. With 40 W standing, the FTW
      container was SIGKILLed (no chance to clear) and started again. The fresh
      driver's first poll found the stranded value and zeroed it:
      clearing orphaned feed from a previous run.
    6. The catalog/UI path connects. The driver's
      write_capabilities = { "solar_pv" } declaration surfaced through
      /api/drivers/catalog (source: local) and the Settings → Devices panel
      from feat(web): turn a driver's write path on from Settings #769 is served and renders the armed state.
    Test setup and how the write was driven
    • Core: dev build of the feat(web): turn a driver's write path on from Settings #769 branch (master + catalog/UI changes),
      host-mounted over the site's v1.15.0 compose install via a
      docker-compose.override.yml bind — the layout, data directory and the
      rest of the stack untouched.
    • Driver: the exact feat(nibe_local): opt-in Solar PV surplus write path (srcfl/ftw#537) device-drivers#46 artifact
      (sha256 69a74a82…) as a local override in the user-drivers directory.
    • The core-side solar-feed forwarder does not exist yet, so nothing sends
      driver_command("solar_pv", …) in a stock system. For the test only, the
      local override carried a config-gated one-shot self-test
      (write.self_test_w: 40) that pushed one value through the standard
      write_pv_surplus path and armed the standard dead-man's switch. It was
      removed afterwards; the site now runs the clean fix: 5 Go-side P1 bugs from Codex review #46 artifact.
    • Config was armed through the same masked-config round-trip the Settings
      UI uses (POST /api/config); the pump credentials never left the site.
    • Register 2107 (the owner's Solar PV master switch) was off throughout,
      which makes any written value behaviorally inert — deliberately so for a
      mechanism test. The driver's policy gate (nonzero command feeds require
      2107 = 1) was not exercised for that reason.

    One pump behavior worth recording: the S-series accepts writes to 2109
    even while 2107 is off. That is convenient for commissioning (FTW can stage
    and clear values without the owner flipping the master switch), but it also
    means a stale value can sit invisibly until the owner enables 2107 — which is
    exactly why the driver's orphan sweep and dead-man's switch matter, and both
    held.

    Still open (unchanged from the list above, minus what this run closed):
    the core-side solar-feed forwarder in the dispatch loop — this test drove the
    write with local instrumentation precisely because the forwarder doesn't
    exist yet; a surplus-driven end-to-end run on a PV-equipped S-series site;
    and the 2107 = 1 behavioral half (what the pump does with the fed value:
    silent-feed timeout, 2108 gross-vs-net).

    🤖 Generated with Claude Code

  4. HuggeK commented on Aug 5, 2026

    @HuggeK
    ContributorAuthor

    The core-side forwarder is no longer missing — the one open item that made the HIL run need local instrumentation is now part of #769 (commit f127729). Enabling the Settings switch is end-to-end: every control tick, core computes the site's solar-attributable export — min(live PV generation, grid export − battery/V2X discharge) — and sends it to the driver, which clamps, deadbands, rate-limits and writes register 2109. Stale site-meter telemetry stops the feed through the existing freshness gate, and the driver's default mode / dead-man switch clears the register.

    With that, this issue's phase 1 is complete across its three parts — driver write path (device-drivers#46), host.http_patch core verb (#716, merged), and now switch + forwarder (#769) — so this issue is wired to close automatically when #769 merges. Phase 2 (add-heat power cap) and phase 3 (hot-water boost via 697) should get their own issues when someone picks them up, so their scope doesn't ride on a closed tracker.

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions