Skip to content

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

Merged
Leitet merged 20 commits into
mainfrom
local-foxess-control-build
Aug 6, 2026
Merged

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

Conversation

@Leitet

@Leitet Leitet commented Aug 3, 2026

Copy link
Copy Markdown
Member

Draft pending #70's rollout decision — CI is now fully green. This is the control implementation behind the hardware findings posted on #70. #69 merged 2026-08-06 and the branch tracks main. The v2 control contract (srcfl/ftw#738, #741) merged the same day, and this driver now implements it — driver_command_v2 / driver_default_mode_v2 with structured results, host-verified write evidence (write ack + setpoint readback), and a default mode that writes the release and proves it. Both package targets build as unsigned candidates and the packages job passes; what remains before un-drafting is the #70 product question of rolling out the control tier, not engineering. The driver runs today as a local operator override on the 1K5 site (v1 entrypoints, kept for exactly that), 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. The register's semantics were reverse-engineered on hardware and then confirmed against nathanmarlor/foxess_modbus: it is the inverter's AC active power, export-positive — not battery power and not a grid target. A naive vendor = -target charges by importing (it displaced PV for 12 h on this site before the model fell out), and a naive positive setpoint curtails PV instead of discharging. The driver therefore translates vendor = pv × PV_AC_EFF − battery_target, with the charge path guarded: BMS ceiling (46018/46019) minus a 200 W margin, a daylight split on PV string voltage, a one-cycle 0 W pause on import/export sign crossings, and hold-at-zero (a commanded 0 is a held setpoint, not a release — releasing produced a ~90 s limit cycle against the host's absorb ceiling). PV_AC_EFF = 0.977 is calibrated from two held-zero hardware points; the residual stays within ±25 W across the daytime range. Base work mode stays Self-use — remote control is an overlay and Self-use is the only fallback.

Safety posture (all hardware-exercised)

  • Vendor timeout 60 s, refreshed every 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.
  • 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; 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 refused at SoC ≥ 99% — the inverter ignores its own Max SoC under remote control — and refused when the BMS charge limit reads below a 250 W floor (full, cold, or BMS hold: native self-use handles surplus better than a fighting setpoint).

Evidence

Validated live on a 1K5-HI-10-V1 in all three regimes: discharge in full sun (−1000 W commanded → battery −1080 W, PV uncurtailed), charge within PV surplus (no import, no curtailment, on target), and charge above surplus (import appears gradually and bounded, balance exact). Hold-at-zero verified across multiple days including the −70 W → +71 W → ±25 W calibration arc recorded in the driver header. Upstream telemetry tests (drivers/tests/test_foxess_h3_smart.py) replay the hardware capture, including per-phase CT and lifetime counters.

Covered since the earlier draft notes

  • Control v2 contract implemented; packages CI job green. The v2 default mode always writes the disable and reads it back — the v1 skip-if-not-ours courtesy toward FoxESS-app schedule periods cannot be proven to the host, so the signed package owns 46001 outright (documented in the driver header).
  • Control-path tests are in the diff (drivers/tests/test_foxess_h3_smart_control.py): statuses, codes, device_state, evidence lists, and the registers actually written, including the failure paths.
  • The migration surfaced a latent v1 bug now fixed: the host's write bindings return error strings rather than raising, so a bare pcall reported failed writes as success.
  • PV curtailment (pv-curtail, 0.8.0) via an AC-setpoint ceiling, hardware-validated: a 2000 W cap pinned export at cap−load while the battery absorbed the excess and PV ran uncurtailed.
  • Versions and manifests synced via bump_driver.py; catalogs regenerated; changelog entry current.

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

Leitet and others added 8 commits August 5, 2026 13:48
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>
Battery lifetime charge/discharge counters (already-read energy block,
added only when it answered), inverter heatsink temp on the pv stream,
rated power parsed from the family name (1K5-HI-<kW>), device-fault
raise/clear from fault codes 39067-69 with a change-latch and the
read-it-or-touch-nothing rule, and the diagnostic metrics this week's
debugging kept needing: inverter state, RC session flag, live vendor
setpoint. Harness gains set_rated_w.

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

The static field scanner attributes every out.field assignment to
every emit that uses the same variable name — pv's temp_c leaked into
the meter's field set. pv_out/bat_out/met_out disambiguate. Capture
fixture grows the CT block to the 8 registers the driver reads and
asserts the per-phase and lifetime-counter fields.

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

Raw DC PV fed into the AC setpoint demands ~3.5% more than PV can
deliver; the inverter covers the gap from the battery -- a steady
~-70 W drain at every held zero, spotted by the site owner from the
dashboard (residual/PV ~= 3.3% across hold samples). PV_AC_EFF = 0.965,
calibrated from those residuals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
0.965 overshot: the residual flipped to +71 W at 4025 W PV. With the
2026-08-05 point (-70 W at 2455 W, factor 1.0) the efficiency curve
shows 0.972 -> 0.983 rising with load; 0.977 keeps held-zero residual
within ~+/-25 W across the daytime range, erring toward a few watts of
charge rather than a steady drain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
The lua DRIVER block moved to 0.7.1 and 0.7.2 while the manifest and
package recipe stayed at 0.7.0 — bump_driver.py exists precisely so a
driver cannot misreport itself to the host; use it. The tested_devices
note still said Read-only on a manifest that declares control: true;
it now records what was actually validated on hardware. Adds the
missing CHANGELOG entry for the control build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
index.yaml, devices.yaml and the support-status pair re-derive from the
manifest; make check requires them committed in the same change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
Brings in the published sigenergy fix and the merged #69 base so the
signed-channel check compares against the channel's current state.

Signed-off-by: Leitet <johan@sourceful-labs.com>
@Leitet
Leitet force-pushed the local-foxess-control-build branch from ec4906a to 017ae50 Compare August 6, 2026 08:32
Leitet and others added 4 commits August 6, 2026 12:48
The host's curtail dispatch sends an absolute cap; on this hardware
the one validated lever is the remote-control AC setpoint, so the cap
becomes a ceiling on it. Hybrid ordering documented in the header: PV
beyond the cap charges the battery first (live BMS limit), genuine
curtailment past that -- hardware-proven with the full-battery
incident. Curtail-only sessions hold battery-at-zero under the
ceiling in daylight and stand down at night so an AC=0 hold can never
block self-use discharge; lease expiry, default mode and cleanup
disarm the cap. Setpoints are now rounded to whole watts before the
word split (the derate made them fractional; only Go's coercion made
that work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
The check's generators run in sequence and stop at the first diff, so
the previous commit staged everything up to devices.yaml but never
reached the support-status pair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
Implements the contract from srcfl/ftw#738/#741, merged today.
driver_command_v2 and driver_default_mode_v2 return structured
results the host can hold to account: applied requires a write ack
plus a setpoint readback the host itself observed, and default mode
always writes the remote-control disable and reads it back. The v1
entrypoints stay for local operator builds; their default mode keeps
the skip-if-not-ours courtesy toward FoxESS-app schedule periods,
which v2 cannot prove and therefore does not offer.

The migration surfaced a latent v1 bug: the host write bindings
return an error string rather than raising, so pcall alone reported
failed writes as success. checked_write/checked_write_multi now test
both layers on every write path.

Package recipe moves to the v2 runtimes (gopher-lua-source-v2,
sourceful.host/*/v2) with driver_default_mode_v2 as the default-mode
entrypoint; both targets build as unsigned candidates locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
Every tested_devices entry said untested, and no hardware ever
validated the 11000-range map. On this vendor's hardware that is a
trap, not a fallback: a Fox ESS inverter answers unknown registers
with silence, so the wrong map produces timeouts with no error -- a
real 1K5 answered none of it. This is not a clean supersession:
foxess_h3_smart covers 1K5-HI and H3-Smart, not the H1 / H3 / H3-PRO
/ AIO-H3 families the old driver claimed, and those lose their only,
unvalidated, listing. Coverage for them can return the way H3-Smart
did -- written against hardware someone actually has.

generate_history now carries forward the record of a driver whose
manifest is gone: the history is the channel's memory of bytes that
ran on hardware, not a mirror of the current catalog. foxess 1.0.0
stays recorded; the channel simply stops offering it. check-versions
already accepts removals (it compares added and changed drivers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Leitet <johan@sourceful-labs.com>
@Leitet
Leitet marked this pull request as ready for review August 6, 2026 13:52
@Leitet

Leitet commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Marking this ready for review. Summary of where it stands and what the review decides:

Engineering state. CI fully green, including packages — the control v2 contract (srcfl/ftw#738/#741) merged this morning and this driver implements it: structured results, host-verified write evidence (write ack + setpoint readback), a default mode that writes the release and reads it back. 63 tests including a dedicated v2 control suite; both package targets build as unsigned candidates.

Hardware evidence. Everything control-shaped here was validated on a live 1K5-HI-10-V1 over four days: discharge in full sun (PV uncurtailed, battery tracks), charge within and above PV surplus (bounded import, exact balance), hold-at-zero calibrated to ±25 W across the daytime range (two-point efficiency calibration recorded in the header), and PV curtailment (2000 W cap → export pinned at cap−load through cloud swings while the battery absorbed the excess). The register semantics (46003/46004 = AC active power, export-positive) were misdiagnosed twice before the model fell out and are confirmed against nathanmarlor/foxess_modbus; the header documents the evidence trail.

What merging decides. tools/ftw_repository.py now builds channel artifacts with control intact when the catalog says control: true ("control follows the catalog"). Merging this manifest is therefore the decision to ship a control-capable driver through the channel — the review is the gate, so saying it out loud: that is what approving this PR means. The stricter signed-package tier (v2 contract, per-site pins) builds green here but has no publication workflow yet; that remains #70.

The foxess removal, honestly framed. The last commit removes the untested 11000-range foxess driver. This is not a clean supersession — foxess_h3_smart covers 1K5-HI/H3-Smart, not the H1 / H3 / H3-PRO / AIO-H3 families the old driver claimed. But every one of those entries said "untested", and on hardware that answers unknown registers with silence, an unvalidated map is a trap, not a fallback (a real 1K5 answered none of it, and the catalog offering it anyway cost a full "device not supported" detour). generate_history.py now carries forward the published record of a removed driver — foxess 1.0.0 stays in driver-history.json; the channel just stops offering it. If reviewers want H1-range coverage kept listed, that commit reverts cleanly on its own.

🤖 Generated with Claude Code

@Leitet
Leitet merged commit 0ea1c51 into main Aug 6, 2026
12 checks passed
@Leitet
Leitet deleted the local-foxess-control-build branch August 6, 2026 14:02
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