Skip to content

draft(foxess_h3_smart): battery control via the remote-control block — hardware-validated, pending the control-tier pipeline (#70) - #71

Draft
Leitet wants to merge 9 commits into
mainfrom
local-foxess-control-build
Draft

draft(foxess_h3_smart): battery control via the remote-control block — hardware-validated, pending the control-tier pipeline (#70)#71
Leitet wants to merge 9 commits into
mainfrom
local-foxess-control-build

Conversation

@Leitet

@Leitet Leitet commented Aug 3, 2026

Copy link
Copy Markdown
Member

Draft on purpose — do not merge. This is the control implementation behind the hardware findings posted on #70, made reviewable. It builds on the read-only driver in #69 (this branch includes those commits) and must not land before (a) #69 merges and (b) the #70 control-pipeline questions have answers — the signed channel builds write-inert read-only artifacts today, so this driver cannot ship through the existing release path at all. It currently runs as a local operator override on the 1K5 site, per Fredrik's own-driver-at-own-risk guidance.

What it does

driver_command("battery", power_w) drives the H3-Smart remote-control block: enable 46001=1, timeout 46002, i32 setpoint 46003/46004 (vendor discharge-positive; site charge-positive setpoint negated on the way out). Base work mode stays Self-use — remote control is an overlay, and Self-use is what the inverter reverts to on timeout.

Safety posture (all hardware-exercised)

  • Vendor timeout 60 s, refreshed every 5 s poll — the inverter reverts itself within a minute if the driver dies. The 60 is load-bearing: the master samples the RC block slowly, and a 15 s session expires before it acts — writes land, read back correctly, and do nothing, with zero error surface. This trap cost half an evening; the comment in the driver preserves it.
  • Driver-side 60 s command lease — a silent EMS releases remote control rather than refreshing a stale setpoint forever.
  • Explicit release in driver_default_mode and driver_cleanup; the disable is only written if this driver enabled the session, so a FoxESS-app strategy period is never stomped (the app uses these same registers — operators must not run app schedule periods alongside).
  • Charge into a full pack refused at the driver (SoC ≥ 99% guard) — the inverter ignores its own Max SoC under remote control.

Evidence

Closed-loop dispatch on a 1K5-HI-10-V1: FTW passive-arbitrage targets ≈ −630 W, battery tracking through the slew ramp (−184 → −465 W), grid import driven 413 → 150 W toward the zero target, SoC descending from 94%. Register state during operation read back as exactly this driver's writes (1 / 60-s window / live setpoint). Mock-harness suite in the session covered enable ordering, refresh, two's-complement setpoints both directions, lease expiry, idempotent release, and full-pack charge refusal — the harness tests are not in this diff and would come with the real control PR once the pipeline shape is known.

Known open items for the real PR: DRIVER/DRIVER_MANIFEST still say read_only in package metadata terms the channel can accept (this build flips the Lua flag only), no structured command results, no lease integration with FTW's control-v2 path (srcfl/ftw#738/#741), and version/manifest sync deliberately not run.

🤖 Generated with Claude Code

@Leitet

Leitet commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Branch updated through v0.5.0 — the setpoint semantics turned out to be inverter AC active power, export-positive (proved on hardware when a naive discharge command curtailed PV in full sun; confirmed against nathanmarlor/foxess_modbus, whose charge path documents the import-displaces-PV behaviour). Discharge is now hardware-validated in daylight; charge ships behind four guards adapted from the reference (BMS-ceiling cap with 200 W margin, daylight split by string voltage, sign-crossing pause, release-on-uncomputable). Full story in the commits.

Leitet and others added 7 commits August 5, 2026 10:57
…on 1K5-HI-10-V1

LOCAL BRANCH ONLY, not for upstream until the control-tier pipeline
(issue #70) exists. Remote-control block 46001-46004, vendor timeout
60 s (master samples slowly; 15 s expires unseen), 60 s command lease,
release in default_mode, charge refused at SoC>=99%. Deployed on
ftw.local as the operator override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
…battery power

Hardware proof 2026-08-05: with the meter at -4 W and the battery
charging on PV surplus, a 500 W charge command made the site import
590 W and the battery charge that much above the surplus. The inverter
obeyed exactly what it was asked: import 500 W. Discharge hid this for
two days because both readings move the grid the same way, so the
host's closed loop converged anyway.

Translate instead, using readings this driver already polls:
  desired_grid = grid_now + (battery_target - battery_now)
Load and pv cancel, so no load measurement is needed, and each poll
recomputes from fresh values rather than integrating.

Verified against four live captures including the runaway that pinned
the site at 4.4 kW import for 12 hours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
…er, not battery power"

This reverts commit cdd1991.

Signed-off-by: Leitet <johan@sourceful-labs.com>
…power

Proved on hardware by an operator watching the roof: with a full
battery in full sun, a discharge command sent as a bare +500 made the
inverter curtail PV from 3191 W to ~600 W instead of discharging. It
had done exactly as asked - put 500 W on the AC side - and with a full
battery, throttling PV was its only route.

One model now explains every observation across three days: the
12-hour grid-import runaway (write -5000, import until the battery's
charge ceiling), the charge test capped by the CV taper, the PV
curtailment above, and why discharge appeared to work on 2026-08-03 -
PV was ~0 that evening, and the naive vendor = -target is correct
exactly when PV is zero.

  inverter_ac = pv_now - battery_target

Verified against five captures, four of them live hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
… implementation's findings

Charge is not a formula: imported power displaces PV before adding to
it, and a naive setpoint spirals (curtailed PV -> lower reading ->
deeper import). v0.5.0 guards it four ways: a live BMS-ceiling cap
(Pwr_limit_Bat_up minus the reference's 200 W PV-breathing margin,
250 W refusal floor), a daylight split on PV string voltage so night
charging imports cleanly, a one-cycle 0 W pause when the setpoint
crosses import/export, and clean release whenever a refresh becomes
uncomputable. Discharge keeps the hardware-validated pv+|target| form.
Fallback work mode stays SELF_USE in both directions, diverging from
the reference on purpose: a dead-man fallback should be boring.

Ten-scenario harness suite covers both directions and every guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
…es not release

Releasing on zero handed the inverter back to native self-use, which
absorbs PV surplus into the battery -- so every time FTW commanded the
battery down to 0 against its absorb ceiling, charging surged back and
FTW fought it down again: a ~90 s limit cycle observed live with
steady 3 kW PV (battery saw-toothing 250..2300 W). Zero now rides the
translation like any setpoint (AC = PV, battery pinned, surplus
exports); release remains on lease expiry and driver_default_mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
Manifest to 0.5.1 with real sha/size; package-source declares what the
driver actually is: read_only false, modbus.write, a battery command
with typed inputs, vendor_autonomous default via driver_default_mode,
and the bounded lease the driver already implements (5 s heartbeat /
60 s max / return-to-default).

The build now fails at the true boundary: ftw-core control requires
the v2 command contract (driver_default_mode_v2), which is exactly the
pipeline gap issue #70 asks about — the schema is otherwise ready.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
@Leitet
Leitet force-pushed the local-foxess-control-build branch from 75c2edc to 6f57027 Compare August 5, 2026 08:57
…talogs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
@Leitet

Leitet commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

CI status is now definitive: dco, drivers (full suite incl. control-claims), signed-channel, history, boundary all green. Manifest and package metadata are fully synced and declare the truth: control: true, modbus.write, a typed battery command, vendor_autonomous default via driver_default_mode, and the bounded lease the driver actually implements (5 s heartbeat / 60 s max / return-to-default — the schema's lease model matched our hardware-derived design 1:1 without modification).

The single remaining red is packages, failing at exactly one line: ftw-core: control requires driver_default_mode_v2. That's the answer to this issue-#70 question about pipeline readiness: the control-package schema is complete and validates, and the only missing link is the v2 command contract on the FTW side (srcfl/ftw#738/#741, unmerged). When v2 ships, porting this driver's two entrypoints is the last step to a fully signed control artifact.

CT phase pairs 38816/38818/38820 (same single block read, count 2->8),
site-sign flipped; amps derived as W/V so the sign carries through --
FTW's fuse bars read l1_a..l3_a signed, negative = export on that
phase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.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.

1 participant