diff --git a/CHANGELOG.md b/CHANGELOG.md index fcfeb47..6189c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] +### Removed +- **`foxess` (H1/H3 11000-range map) — removed untested.** Every tested_devices entry said "Community driver, untested", and no hardware has ever validated the map. This is not a clean supersession: `foxess_h3_smart` covers the 1K5-HI and H3-Smart families, not the H1 / H3 / H3-PRO / AIO-H3 families the old driver claimed — those lose their only, unvalidated, listing. The removal is still right on this vendor's hardware behaviour: a Fox ESS inverter answers unknown registers with silence, so a wrong-map driver produces no error, only timeouts — a real 1K5 answered none of the 11000-range map, and the catalog offering it anyway cost that operator a full "device not supported" detour. A catalog that reports what is known must not list a map nobody has seen answer. H1-range coverage can return the way H3-Smart did: a driver written against hardware someone actually has. + ### Added +- **`foxess_h3_smart` 0.9.0** — battery control through the vendor remote-control block, hardware-validated in charge, discharge and hold on a 1K5-HI-10-V1. The setpoint at 46003/46004 is the inverter's AC active power, export-positive — not battery power and not a grid target — so the driver translates `vendor = pv × PV_AC_EFF − battery_target` and guards the charge path: 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 charge refusal at SoC ≥ 99% (the inverter ignores its own Max SoC under remote control). Zero is a held setpoint, not a release — releasing let native self-use surge charging back against the host's ceiling in a ~90 s limit cycle. Two dead-man's switches: the vendor timeout at 46002 (≥ 60 s — the master samples that block slowly and a shorter session expires unseen) and a 60 s driver-side command lease; `driver_default_mode` releases remote control explicitly and SELF_USE is the only fallback work mode. `PV_AC_EFF = 0.977` is calibrated from two held-zero hardware points (−70 W residual at 2455 W PV, +71 W at 4025 W) — the efficiency curve rises with load, so one constant lands within ±25 W across the daytime range, erring toward a few watts of charge rather than a steady drain. 0.8.0 adds `pv-curtail` (`curtail` / `curtail_disable`), gated on the operator's `supports_pv_curtail` opt-in: the cap is a ceiling on the same AC setpoint — on a hybrid, PV beyond the cap charges the battery first (up to the live BMS limit) and genuine curtailment begins past that, which is the ordering the negative-export guard wants; a curtail-only session holds battery-at-zero under the ceiling in daylight and stands down at night so it cannot block self-use discharge. 0.9.0 implements the **control v2 contract** (srcfl/ftw#738/#741) — `driver_command_v2` / `driver_default_mode_v2` return structured results whose "applied"/"defaulted" claims the host verifies against its own write-scope evidence (write ack + setpoint readback), and default mode always writes the release and reads it back; the v1 entrypoints remain for local operator builds, whose default mode keeps the skip-if-not-ours courtesy toward FoxESS-app schedule periods. The migration also surfaced that the host's write bindings return error strings rather than raising, so a bare `pcall` around a write reports failure as success — every write now checks both layers. `drivers/tests/test_foxess_h3_smart_control.py` holds the v2 results to the contract: statuses, codes, device_state, evidence lists, and the registers actually written. Also since 0.1.0: per-phase grid CT (voltage, power, amps per phase), lifetime energy counters, inverter temperature and state metrics, and a fault-code latch on 39067..39069 - **A driver-authoring rule for the hybrid inverter that has no battery** — *A hybrid inverter may have no battery* in `docs/WRITING-A-DRIVER.md`, and rule 8 in `drivers/lua/GUIDELINES.md`. The existing rules cover a read that **failed**; this is the case where nothing failed — the device is healthy, every register answered, and the battery still is not there. Nearly every hybrid inverter is sold both with storage and without it under one model number and one register map, so a PV-only site is not an edge case, it is half the product line. The SG12RT already cited at the top of that document is this same fact arriving as an outage rather than as a wrong number. The rule: fill each battery field only from a register that answered, emit the DER only when at least one did, and **detect** it rather than reading it off the model number or asking the operator to declare it — the site nobody told the driver about is exactly the one that reports wrong. Records how the absence actually arrives, which is vendor-specific and cannot be assumed from one example: registers that go silent (Sigenergy), that answer a plain zero, that answer `0xFFFF`/`0x7FFFFFFF`/NaN (`sma` and `solis` already carry sentinel helpers), or that fault - The rule is stated with the catalog measured rather than asserted: **24 drivers emit both `pv` and `battery`, and 20 of them emit the battery DER with no guard on whether any battery register answered.** Two of the 20 (`ferroamp`, `zap`) gate it on configuration or on API discovery instead — better than nothing, and still not detection, which is why the rule names the difference. `sigenergy` 1.1.3 below is the worked example of the fix. The other 19 are not touched here: each is its own driver, its own register map and its own version, and a sweep that changes nineteen drivers at once is not reviewable - **`ders` deliberately stays as it is** in all of them. It describes what a driver can produce, not what one site has, and it reaches the signed artifact — so removing `battery` from a hybrid's manifest would cost a version and describe the driver less accurately than before diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 29e2003..ff1ffc8 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -50,10 +50,8 @@ Catalog source is not proof that a target can install or run a driver. | ferroamp_dc2_v2x | 2.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | ferroamp_modbus | 2.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | ferroamp_modbus | 2.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| foxess | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| foxess | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| foxess_h3_smart | 0.1.0 | ftw-core | not_assessed | 0.1.0 | — | not_recorded | — | not_assessed | no | -| foxess_h3_smart | 0.1.0 | blixt-l1 | not_assessed | 0.1.0 | — | not_recorded | — | not_assessed | no | +| foxess_h3_smart | 0.9.0 | ftw-core | not_assessed | 0.9.0 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.9.0 | blixt-l1 | not_assessed | 0.9.0 | — | not_recorded | — | not_assessed | yes | | fronius | 2.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | fronius | 2.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | fronius_api | 1.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | diff --git a/devices.yaml b/devices.yaml index 813d6df..5608a15 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,77 +393,22 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.1.0" + version: "0.9.0" ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Telemetry validated on 1K5-HI-10-V1 hardware: PV string power agrees with V*A, and pv + battery + load balances the grid CT. Read-only." - - name: "AIO-H3 (All-in-One)" - variants: [AIO-H3-10.0, AIO-H3-3.0, AIO-H3-5.0, AIO-H3-6.0, AIO-H3-8.0] - regions: [] - protocols: - - protocol: modbus - driver: "foxess" - version: "1.1.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, untested" - - name: "H1 (Single-Phase Hybrid)" - variants: [H1-3.0, H1-3.7, H1-4.6, H1-5.0, H1-6.0] - regions: [] - protocols: - - protocol: modbus - driver: "foxess" - version: "1.1.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, untested" - - name: "H3 (Three-Phase Hybrid)" - variants: [H3-10.0, H3-12.0, H3-5.0, H3-6.0, H3-8.0] - regions: [] - protocols: - - protocol: modbus - driver: "foxess" - version: "1.1.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, untested" - - name: "H3 PRO" - variants: [H3-PRO-10.0, H3-PRO-15.0, H3-PRO-20.0] - regions: [] - protocols: - - protocol: modbus - driver: "foxess" - version: "1.1.1" - ders: [pv, battery, meter] - control: false + control: true firmware_versions: "" - notes: "Community driver, untested" + notes: "Telemetry and battery control validated on 1K5-HI-10-V1 hardware: PV string power agrees with V*A, pv + battery + load balances the grid CT, and battery dispatch through the remote-control block is validated in charge, discharge and hold." - name: "H3-Smart" variants: [] regions: [] protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.1.0" + version: "0.9.0" ders: [pv, battery, meter] - control: false + control: true firmware_versions: "" notes: "Shares the 1K5 register map; not yet tested on H3-Smart hardware." - - name: "KH Series" - variants: [KH-3.0, KH-3.7, KH-5.0] - regions: [] - protocols: - - protocol: modbus - driver: "foxess" - version: "1.1.1" - ders: [pv, battery, meter] - control: false - firmware_versions: "" - notes: "Community driver, untested" - name: "Fronius" model_families: - name: "Fronius Primo (Classic)" diff --git a/drivers/lua/foxess.lua b/drivers/lua/foxess.lua deleted file mode 100644 index 6a77ea2..0000000 --- a/drivers/lua/foxess.lua +++ /dev/null @@ -1,241 +0,0 @@ --- Fox ESS H1/H3 Series Inverter Driver --- Emits: PV, Battery, Meter --- Register type: HOLDING (FC 0x03) --- Port: 502 --- Community tier (untested) --- Register map from nathanmarlor/foxess_modbus community - -PROTOCOL = "modbus" - --- Registers this device has stopped answering. --- --- The host counts every failed host.modbus_read against the poll whether or --- not this driver caught the error — "driver_poll: N of M modbus reads --- failed". So a register retried on every poll costs a failed poll on every --- poll, and the stale-telemetry watchdog takes the driver offline. The site --- then reports nothing at all, which is worse than reporting one field less. --- --- Three attempts absorb a transient blip; after that we stop asking. A --- restart re-probes, so firmware that gains the register is picked up. -local GIVE_UP_AFTER = 3 -local read_failures = {} - -local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end - local ok, regs = pcall(host.modbus_read, addr, count, kind) - if ok and regs and regs[1] ~= nil then - read_failures[addr] = nil - return regs - end - local failures = (read_failures[addr] or 0) + 1 - read_failures[addr] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "FoxESS: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) - end - return nil -end - -function driver_init(config) - host.set_make("FoxESS") -end - -function driver_poll() - -- ---- PV ---- - - -- PV1 power: 11000, I16, W - local pv1w_regs = probe_read(11000, 1, "holding") - local pv1_w = 0 - if pv1w_regs then - pv1_w = host.decode_i16(pv1w_regs[1]) - end - - -- PV1 current: 11001, U16 × 0.1A; PV1 voltage: 11002, U16 × 0.1V - local pv1_regs = probe_read(11001, 2, "holding") - local mppt1_a, mppt1_v = 0, 0 - if pv1_regs then - mppt1_a = pv1_regs[1] * 0.1 - mppt1_v = pv1_regs[2] * 0.1 - end - - -- PV2 power: 11003, I16, W - local pv2w_regs = probe_read(11003, 1, "holding") - local pv2_w = 0 - if pv2w_regs then - pv2_w = host.decode_i16(pv2w_regs[1]) - end - - -- PV2 current: 11004, U16 × 0.1A; PV2 voltage: 11005, U16 × 0.1V - local pv2_regs = probe_read(11004, 2, "holding") - local mppt2_a, mppt2_v = 0, 0 - if pv2_regs then - mppt2_a = pv2_regs[1] * 0.1 - mppt2_v = pv2_regs[2] * 0.1 - end - - local pv_w = pv1_w + pv2_w - - -- Grid frequency: 11014, U16 × 0.01Hz - local hz_regs = probe_read(11014, 1, "holding") - local hz = 0 - if hz_regs then - hz = hz_regs[1] * 0.01 - end - - -- Total PV energy: 11070-11071, U32 BE × 0.1 kWh - local pvgen_regs = probe_read(11070, 2, "holding") - local pv_gen_wh = 0 - if pvgen_regs then - pv_gen_wh = host.decode_u32_be(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 - end - - -- Emit PV telemetry (W always negative for generation) - host.emit("pv", { - W = -pv_w, - mppt1_v = mppt1_v, - mppt1_a = mppt1_a, - mppt2_v = mppt2_v, - mppt2_a = mppt2_a, - total_generation_Wh = pv_gen_wh, - }) - - -- ---- Battery ---- - - -- Battery power: 11034, I16, W (positive=charge, negative=discharge) - local bw_regs = probe_read(11034, 1, "holding") - local bat_w = 0 - if bw_regs then - bat_w = host.decode_i16(bw_regs[1]) - end - - -- Battery current: 11035, I16 × 0.1A - local ba_regs = probe_read(11035, 1, "holding") - local bat_a = 0 - if ba_regs then - bat_a = host.decode_i16(ba_regs[1]) * 0.1 - end - - -- Battery voltage: 11036, U16 × 0.1V - local bv_regs = probe_read(11036, 1, "holding") - local bat_v = 0 - if bv_regs then - bat_v = bv_regs[1] * 0.1 - end - - -- Battery SoC: 11038, U16, % - local bsoc_regs = probe_read(11038, 1, "holding") - local bat_soc = 0 - if bsoc_regs then - bat_soc = bsoc_regs[1] / 100 -- percent to fraction - end - - -- Battery temperature: 11039, I16 × 0.1C - local btemp_regs = probe_read(11039, 1, "holding") - local bat_temp = 0 - if btemp_regs then - bat_temp = host.decode_i16(btemp_regs[1]) * 0.1 - end - - -- Emit Battery telemetry - host.emit("battery", { - W = bat_w, - V = bat_v, - A = bat_a, - SoC_nom_fract = bat_soc, - temperature_C = bat_temp, - }) - - -- ---- Meter ---- - - -- Grid/Meter power: 11021, I16, W (positive=import) - local mw_regs = probe_read(11021, 1, "holding") - local meter_w = 0 - if mw_regs then - meter_w = host.decode_i16(mw_regs[1]) - end - - -- Phase voltages: 11009, 11011, 11013, U16 × 0.1V - local lv1_regs = probe_read(11009, 1, "holding") - local l1_v = 0 - if lv1_regs then - l1_v = lv1_regs[1] * 0.1 - end - - local lv2_regs = probe_read(11011, 1, "holding") - local l2_v = 0 - if lv2_regs then - l2_v = lv2_regs[1] * 0.1 - end - - local lv3_regs = probe_read(11013, 1, "holding") - local l3_v = 0 - if lv3_regs then - l3_v = lv3_regs[1] * 0.1 - end - - -- Phase currents: 11010, 11012, 11014, U16 × 0.1A - local la1_regs = probe_read(11010, 1, "holding") - local l1_a = 0 - if la1_regs then - l1_a = la1_regs[1] * 0.1 - end - - local la2_regs = probe_read(11012, 1, "holding") - local l2_a = 0 - if la2_regs then - l2_a = la2_regs[1] * 0.1 - end - - -- Note: register 11014 is also grid frequency; for 3-phase current L3 - -- Fox ESS uses the same register address. Read separately if needed. - local la3_regs = probe_read(11014, 1, "holding") - local l3_a = 0 - if la3_regs then - -- This register is shared with frequency on single-phase models - -- On 3-phase (H3), this holds L3 current × 0.1A - l3_a = la3_regs[1] * 0.1 - end - - -- Import energy: 11072-11073, U32 BE × 0.1 kWh - local imp_regs = probe_read(11072, 2, "holding") - local import_wh = 0 - if imp_regs then - import_wh = host.decode_u32_be(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end - - -- Export energy: 11074-11075, U32 BE × 0.1 kWh - local exp_regs = probe_read(11074, 2, "holding") - local export_wh = 0 - if exp_regs then - export_wh = host.decode_u32_be(exp_regs[1], exp_regs[2]) * 0.1 * 1000 - end - - -- Emit Meter telemetry - host.emit("meter", { - W = meter_w, - L1_V = l1_v, - L2_V = l2_v, - L3_V = l3_v, - L1_A = l1_a, - L2_A = l2_a, - L3_A = l3_a, - Hz = hz, - total_import_Wh = import_wh, - total_export_Wh = export_wh, - }) - - return 5000 -end - -function driver_command(action, power_w, cmd) - host.log("FoxESS control not yet implemented: " .. action) - return false -end - -function driver_default_mode() -end - -function driver_cleanup() - -- nothing to clean up -end diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index d8cffb0..5a314bc 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -19,21 +19,125 @@ -- -- The distinct H1/H3 (11000-range) register map lives in the separate -- `foxess` driver. - +-- +-- LOCAL CONTROL BUILD (operator's own risk, not the signed channel): +-- battery dispatch through the vendor remote-control block. +-- +-- ============================ SEMANTICS ============================ +-- The setpoint at 46003/46004 is the INVERTER'S AC ACTIVE POWER, +-- export-positive. It is not battery power and not a grid-meter +-- target. The inverter reaches the number by any means available -- +-- running PV, curtailing PV, charging, or discharging -- bounded by +-- what the battery accepts at that moment. +-- +-- Hardware evidence behind this model (1K5-HI-10-V1, 2026-08-03/05), +-- each once misdiagnosed before the model fell out: +-- * write -5000 ("charge 5 kW" naively): imported 4.5 kW from the +-- grid for 12 h against an idle plan -- import runs until the +-- battery's charge ceiling, and imported power DISPLACES PV +-- before adding to it; +-- * write +500 with a full battery in full sun ("discharge 500" +-- naively): the inverter CURTAILED PV 3191 W -> 600 W instead of +-- discharging -- the operator saw the array throttle, which the +-- meter data alone could not reveal; +-- * bare `vendor = -target` appeared to work for discharge on +-- 2026-08-03 only because PV was ~0 that evening; the naive form +-- is correct exactly when PV is zero. +-- The same semantics are confirmed independently by the +-- nathanmarlor/foxess_modbus remote-control implementation, whose +-- comments describe the import-displaces-PV behaviour verbatim. +-- +-- ========================== TRANSLATION ============================ +-- DISCHARGE (battery_target < 0), hardware-validated 2026-08-05 +-- (commanded -1000 in full sun: battery -1080, PV uncurtailed): +-- vendor = pv_now + |battery_target| +-- PV passes through at max; the battery fills the difference. The +-- ~8% overshoot is DC->AC conversion loss (the AC side is what we +-- set); the host's closed loop absorbs it. +-- +-- CHARGE (battery_target > 0) is NOT a formula but a guarded one: +-- imported power displaces PV first, and a naive setpoint spirals +-- (curtailed PV -> lower reading -> deeper import). Guards, in order: +-- 1. BMS ceiling: read Pwr_limit_Bat_up (46018/46019) fresh at the +-- command and on every refresh; effective charge is capped at +-- that limit minus a 200 W margin. The margin is the reference +-- implementation's finding: command right at the limit and the +-- inverter clips PV while the battery takes ~50 W less than it +-- could -- the gap is what lets PV fill in. Below a 250 W floor +-- the battery is effectively refusing charge (full, cold, BMS +-- hold): release remote control and report why, letting native +-- self-use surplus-charge instead. +-- 2. Daylight split (PV string VOLTAGE >= 70 V -- voltage says the +-- panels are awake even when power is ~0 at dawn; power says +-- nothing at night): +-- daylight: vendor = pv_now - p_eff (import only appears +-- implicitly when p_eff exceeds live PV) +-- night: vendor = -p_eff (pure import; nothing +-- to displace -- the reference does exactly this) +-- 3. Import/export crossing pause: when the computed setpoint +-- changes sign between refreshes, write one cycle of 0 W first. +-- Reference finding: crossing in one step can oscillate. +-- 4. Bounded worst case, documented deliberately: if the inverter +-- chooses to curtail PV rather than charge (seen only with a +-- full battery so far), the fresh-PV recomputation converges to +-- import-only charging capped by guard 1 -- wasteful of PV but +-- bounded; it cannot run away. If testing shows curtailment at +-- healthy SoC too, the next step is the reference's P-loop on +-- import power with battery-uptake feedback, not a bigger cap. +-- +-- Divergence from the reference, on purpose: it swaps the fallback +-- work mode per direction (FEED_IN_FIRST for discharge, BACK_UP for +-- charge) to bias the inverter's behaviour if remote control drops. +-- This driver keeps SELF_USE as the only fallback: it is the mode the +-- operator runs, it never imports to the battery and never exports +-- the battery, and a dead-man fallback should be boring. +-- +-- ============================ SAFETY =============================== +-- Two dead-man's switches: the vendor-side timeout (46002, refreshed +-- every poll; must be >= 60 s -- the master samples this block slowly +-- and a 15 s session expires unseen) and a driver-side 60 s command +-- lease. driver_default_mode releases remote control explicitly. +-- Charge is refused at SoC >= 99%: the inverter ignores its own Max +-- SoC under remote control (reference finding, and this battery +-- reached 100% under FTW charge on 2026-08-05). +-- +-- ========================= PV CURTAILMENT ========================== +-- `curtail` / `curtail_disable` ride the same remote-control block: +-- this hardware has one validated lever, the AC setpoint, so a +-- curtail cap is a CEILING on the inverter's AC output. Physics of a +-- hybrid: PV beyond the cap charges the battery first (up to the live +-- BMS limit) and genuine PV curtailment begins only past that -- +-- hardware-proven 2026-08-05, when a full battery plus an AC setpoint +-- below production curtailed the array 3191 W -> 600 W. That ordering +-- is what the host's negative-export guard wants: export never +-- exceeds cap minus house load, and energy is stored, not thrown +-- away, whenever the battery has room. Consequences, documented +-- rather than hidden: +-- * a cap ABOVE current PV holds the battery at zero (the hold +-- formula is the ceiling's floor) where native self-use would +-- have charged -- the planner only sends binding caps and the +-- manual hold is bounded at 30 min, so this is accepted; +-- * at night there is no PV to cap and an AC=0 hold would block +-- self-use discharge, so a curtail-only session stands down while +-- the cap stays armed; it re-engages at first daylight; +-- * a battery command and a cap compose as min(): the battery may +-- charge above its commanded target while the cap binds (the +-- inverter balances into it), bounded by the BMS ceiling. +-- DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.1.0", + version = "0.9.0", host_api_min = 1, - host_api_max = 1, + host_api_max = 2, protocols = { "modbus" }, - capabilities = { "pv", "battery", "meter" }, - description = "Fox ESS H3-Smart register map: 1K5-HI series and H3-Smart three-phase hybrids. Modbus-TCP port 502, unit 247.", + capabilities = { "pv", "battery", "meter", "pv-curtail" }, + description = "Fox ESS H3-Smart register map: 1K5-HI series and H3-Smart three-phase hybrids. Modbus-TCP port 502, unit 247. Local control build: battery dispatch via the remote-control block.", authors = { "Sourceful Labs AB" }, tested_models = { "1K5-HI-10-V1" }, verification_status = "experimental", - read_only = true, + read_only = false, } PROTOCOL = "modbus" @@ -46,7 +150,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.1.0", + version = "0.9.0", role = "inverter", requires = {}, options = {}, @@ -55,8 +159,11 @@ DRIVER_MANIFEST = { "pv.W", "pv.mppts", "pv.total_generation_Wh", "battery.W", "battery.V", "battery.A", "battery.SoC_nom_fract", "battery.temperature_C", + "battery.total_charge_Wh", "battery.total_discharge_Wh", "meter.W", "meter.Hz", "meter.L1_V", "meter.L2_V", "meter.L3_V", + "meter.L1_W", "meter.L2_W", "meter.L3_W", + "meter.L1_A", "meter.L2_A", "meter.L3_A", "meter.total_import_Wh", "meter.total_export_Wh", }, static = { "make" }, @@ -77,9 +184,11 @@ local POWER_COUNT = 20 -- pv4 39285... local PV_ADDR = 39279 local PV_COUNT = 8 --- Grid CT total power, i32, 0.1 W units. Vendor sign: positive = export. +-- Grid CT power, i32 pairs in 0.1 W units. Vendor sign: positive = +-- export. Total at 38814; per-phase R/S/T at 38816/38818/38820 — the +-- site meter's per-phase data feeds FTW's fuse bars. local CT_ADDR = 38814 -local CT_COUNT = 2 +local CT_COUNT = 8 -- Energy counters, u32 pairs in 0.01 kWh: solar 39601.., feed-in -- 39613.., grid consumption 39617... local ENERGY_ADDR = 39601 @@ -88,7 +197,79 @@ local ENERGY_COUNT = 18 local SOC_ADDR = 37612 local BAT_TEMP_ADDR = 37611 +-- Remote control block (single-register writes only for enable/timeout; +-- the setpoint is one multi-register write, high word at 46003). +local RC_ENABLE_ADDR = 46001 +local RC_TIMEOUT_ADDR = 46002 +local RC_POWER_ADDR = 46003 +local WORK_MODE_ADDR = 49203 +local WORK_MODE_SELF_USE = 1 + +-- The inverter reverts to its fallback work mode when the timeout +-- expires without a refresh. Hardware-derived floor: the master +-- processor samples the remote-control block slowly, and a 15 s +-- session expired before it ever acted — writes landed, read back +-- correctly, and did nothing. The FoxESS app's own force periods use +-- these same registers with a period-length timeout. 60 s is long +-- enough for the master to act and still reverts the inverter within +-- a minute if this driver dies; the driver-side lease below is the +-- tighter of the two guards. +local RC_TIMEOUT_S = 60 +-- Battery charge ceiling register (i32 pair, high word at 46018): +-- how much the battery accepts right now, BMS included. +local BAT_CHARGE_LIMIT_ADDR = 46018 +-- See the CHARGE section of the header for all three of these. +local CHARGE_BMS_MARGIN_W = 200 +local CHARGE_BMS_FLOOR_W = 250 +local PV_VOLTS_DAYLIGHT = 70 +-- The PV reading is DC-side; the AC terminals see less after +-- conversion, and feeding raw DC PV into the AC setpoint makes the +-- inverter cover the gap from the battery. Two hardware calibration +-- points on this unit's efficiency curve (residual at held zero): +-- 2026-08-05: -70 W at 2455 W PV, factor 1.0 -> eta ~ 0.972 +-- 2026-08-06: +71 W at 4025 W PV, factor 0.965 -> eta ~ 0.983 +-- Efficiency rises with load, so one constant cannot zero both ends; +-- 0.977 keeps the residual within ~+/-25 W across the daytime range, +-- erring toward a few watts of charge (benign) in strong sun rather +-- than a steady drain. Recalibrate from held-zero residuals if panels +-- or firmware change. +local PV_AC_EFF = 0.977 +-- The driver-side lease: without a fresh battery command inside this +-- window, release remote control rather than keep refreshing a stale +-- setpoint forever. +local RC_LEASE_MS = 60000 + local identity_reported = false +local rated_w = nil +-- Device-fault latch: only raise/clear on a status block we actually +-- read, and only write the host state on a change (mirrors sungrow). +local fault_active = nil + +-- Remote-control state. rc_enabled tracks whether *we* enabled it: the +-- FoxESS app's own strategy periods use the same register, so a driver +-- that did not enable remote control must never write the disable. +local rc_enabled = false +local rc_target_w = nil -- site convention: positive = charge +local rc_command_ms = 0 +-- PV curtail cap (AC-output ceiling, W). nil = none. Armed by the +-- `curtail` action; only honoured when the operator opted in via +-- supports_pv_curtail (the host injects _supports_pv_curtail). +local pv_curtail_enabled = false +local curtail_cap_w = nil +local last_soc_fract = nil +-- PV generation as a positive magnitude, from the last poll. The +-- setpoint translation needs it; a command that arrives before the +-- first PV reading is refused rather than guessed. +local last_pv_w = nil +-- Highest PV string voltage from the last poll: the daylight detector. +local last_pv_volts = nil +-- Last AC setpoint written, for the sign-crossing pause. +local prev_vendor_w = nil + +-- Sanity bound on the computed setpoint: the translation subtracts two +-- live values, and one bad sample should not ask this hardware for +-- something absurd. Comfortably outside its ~10 kW rating both ways. +local MAX_SETPOINT_W = 15000 local function reg(regs, base, addr) return regs[addr - base + 1] @@ -144,6 +325,12 @@ local function report_identity() if serial ~= "" then host.set_sn(serial) end + -- Rated power straight from the family name: 1K5-HI--V1. + local kw = model:match("^1K5%-HI%-(%d+)") + if kw then + rated_w = tonumber(kw) * 1000 + pcall(host.set_rated_w, rated_w) + end if not model:find("^1K5%-") and not model:find("^H3%-") then host.log("warn", "foxess_h3_smart: model '" .. model .. "' is not a known H3-Smart-map family; telemetry may be wrong") @@ -153,6 +340,154 @@ end function driver_init(config) host.set_make("FoxESS") + pv_curtail_enabled = config ~= nil and config._supports_pv_curtail == true +end + +local function read_battery_charge_limit_w() + local regs = read(BAT_CHARGE_LIMIT_ADDR, 2) + if not regs then + return nil + end + -- Sign varies by model/firmware (the reference expects negative, + -- this hardware has read positive); the magnitude is the limit. + local v = math.abs(host.decode_i32_be(regs[1], regs[2])) + if v > 20000 then + return nil -- implausible for this hardware; treat as unreadable + end + return v +end + +-- Translate a battery target (site convention, charge-positive) into +-- the vendor AC setpoint. Returns vendor watts, or nil + reason. +-- See the header for the model and every guard's justification. +local function compute_vendor(battery_target_w) + if battery_target_w <= 0 then + -- Discharge, and HOLD-AT-ZERO: AC = pv - target, so target 0 pins + -- the battery at 0 with all PV flowing to house + grid. Zero must + -- be an enforced setpoint, not a release: this inverter's + -- uncommanded state is self-use, which absorbs the surplus into + -- the battery -- and a controller that commands 0, releases, and + -- watches native charging surge back gets a ~90 s limit cycle + -- (observed live 2026-08-05: steady 3 kW PV, battery saw-toothing + -- 250..2300 W against FTW's absorb ceiling). + if last_pv_w == nil then + return nil, "no PV reading yet" + end + return last_pv_w * PV_AC_EFF - battery_target_w + end + -- Charge: guard 1, the live BMS ceiling. + local limit = read_battery_charge_limit_w() + if limit == nil then + return nil, "battery charge limit unreadable" + end + if limit < CHARGE_BMS_FLOOR_W then + return nil, "battery is not accepting charge now" + end + local p_eff = math.min(battery_target_w, limit - CHARGE_BMS_MARGIN_W) + -- Guard 2: daylight by string voltage, not power. + if (last_pv_volts or 0) >= PV_VOLTS_DAYLIGHT then + if last_pv_w == nil then + return nil, "no PV reading yet" + end + return last_pv_w * PV_AC_EFF - p_eff + end + return -p_eff +end + +-- The host's write bindings return an error string on failure and +-- nothing on success -- they do not raise. pcall alone therefore +-- reports success for a failed write; both layers must be checked. +local function checked_write(addr, value) + local ok, err = pcall(host.write, addr, value) + if ok and err == nil then + return true + end + return false, tostring(err) +end + +local function checked_write_multi(addr, values) + local ok, err = pcall(host.write_registers, addr, values) + if ok and err == nil then + return true + end + return false, tostring(err) +end + +local function write_setpoint(battery_target_w) + local vendor, why = compute_vendor(battery_target_w) + if vendor == nil then + return false, why + end + -- Whole watts: the PV derate makes vendor fractional, and the word + -- split below assumes an integer (a fractional low word reaches the + -- host as a float and only works because Go coerces it). + vendor = math.floor(vendor + 0.5) + if curtail_cap_w ~= nil and vendor > curtail_cap_w then + vendor = curtail_cap_w + end + if vendor > MAX_SETPOINT_W then + vendor = MAX_SETPOINT_W + elseif vendor < -MAX_SETPOINT_W then + vendor = -MAX_SETPOINT_W + end + -- Guard 3: one cycle of 0 W when crossing import/export. + if prev_vendor_w ~= nil and + ((prev_vendor_w > 0 and vendor < 0) or (prev_vendor_w < 0 and vendor > 0)) then + vendor = 0 + end + prev_vendor_w = vendor + -- Two's complement from the signed value directly. Adding 2^32 first + -- would be exact only where Lua numbers are doubles; Lua's modulo is + -- floored, so this yields the same two words while every operand + -- stays small enough for a single-precision host. + local hi = math.floor(vendor / 65536) % 65536 + local lo = vendor % 65536 + return checked_write_multi(RC_POWER_ADDR, { hi, lo }) +end + +local function apply_remote_control(site_w) + if not rc_enabled then + -- Fallback first: if we vanish and the timeout fires, the inverter + -- lands in self-use rather than whatever mode was last configured. + local ok, mode = pcall(host.modbus_read, WORK_MODE_ADDR, 1, "holding") + if ok and mode and mode[1] ~= nil and mode[1] ~= WORK_MODE_SELF_USE then + checked_write(WORK_MODE_ADDR, WORK_MODE_SELF_USE) + end + if not checked_write(RC_TIMEOUT_ADDR, RC_TIMEOUT_S) then return false end + if not checked_write(RC_ENABLE_ADDR, 1) then return false end + rc_enabled = true + end + return write_setpoint(site_w) +end + +local function release_remote_control() + rc_target_w = nil + prev_vendor_w = nil + if rc_enabled then + rc_enabled = false + checked_write(RC_ENABLE_ADDR, 0) + end +end + +-- The effective battery target for the RC session, or nil when no +-- session should be active right now. A curtail-only session holds +-- the battery at zero under the cap's ceiling -- daylight only (see +-- the curtailment header section). +local function session_target() + if rc_target_w ~= nil then + return rc_target_w + end + if curtail_cap_w ~= nil and (last_pv_volts or 0) >= PV_VOLTS_DAYLIGHT then + return 0 + end + return nil +end + +-- Full stand-down: cap disarmed too. Lease expiry, default mode and +-- cleanup land here -- a dead EMS must not leave a cap armed. +local function release_all() + curtail_cap_w = nil + release_remote_control() end function driver_poll() @@ -190,52 +525,138 @@ function driver_poll() mppts[s] = nil end - local out = {} - out.W = -pv_w - out.mppts = mppts + local pv_out = {} + pv_out.W = -pv_w + pv_out.mppts = mppts + if rated_w then + pv_out.rated_w = rated_w + end + -- Inverter heatsink temperature rides the pv stream, sungrow-style. + pv_out.temp_c = host.decode_i16(reg(status, STATUS_ADDR, 39141)) * 0.1 if energy then - out.total_generation_Wh = u32(energy, ENERGY_ADDR, 39601) * 10 + pv_out.total_generation_Wh = u32(energy, ENERGY_ADDR, 39601) * 10 end - host.emit("pv", out) + last_pv_w = pv_w + local volts = 0 + for s = 1, #mppts do + if mppts[s].V > volts then volts = mppts[s].V end + end + last_pv_volts = volts + host.emit("pv", pv_out) end -- ---- Battery ---- -- Vendor sign: positive = discharge. Site convention: positive = charge. if power then - local out = {} - out.W = -i32(power, POWER_ADDR, 39237) - out.V = reg(power, POWER_ADDR, 39227) * 0.1 - out.A = -i32(power, POWER_ADDR, 39228) * 0.001 + local bat_out = {} + bat_out.W = -i32(power, POWER_ADDR, 39237) + bat_out.V = reg(power, POWER_ADDR, 39227) * 0.1 + bat_out.A = -i32(power, POWER_ADDR, 39228) * 0.001 local soc = read(SOC_ADDR, 1) if soc then local fract = soc[1] / 100 if fract >= 0 and fract <= 1 then - out.SoC_nom_fract = fract + bat_out.SoC_nom_fract = fract + last_soc_fract = fract end end local bat_temp = read(BAT_TEMP_ADDR, 1) if bat_temp then - out.temperature_C = host.decode_i16(bat_temp[1]) * 0.1 + bat_out.temperature_C = host.decode_i16(bat_temp[1]) * 0.1 + end + -- Lifetime counters live in the energy block this poll already + -- read; added only when it answered (a zero would read as a reset + -- meter). + if energy then + bat_out.total_charge_Wh = u32(energy, ENERGY_ADDR, 39605) * 10 + bat_out.total_discharge_Wh = u32(energy, ENERGY_ADDR, 39609) * 10 end - host.emit("battery", out) + host.emit("battery", bat_out) end -- ---- Meter ---- -- Vendor sign: positive = export. Site convention: positive = import. if ct then - local out = {} - out.W = -i32(ct, CT_ADDR, CT_ADDR) * 0.1 + local met_out = {} + met_out.W = -i32(ct, CT_ADDR, CT_ADDR) * 0.1 if status then - out.Hz = reg(status, STATUS_ADDR, 39139) * 0.01 - out.L1_V = reg(status, STATUS_ADDR, 39123) * 0.1 - out.L2_V = reg(status, STATUS_ADDR, 39124) * 0.1 - out.L3_V = reg(status, STATUS_ADDR, 39125) * 0.1 + met_out.Hz = reg(status, STATUS_ADDR, 39139) * 0.01 + met_out.L1_V = reg(status, STATUS_ADDR, 39123) * 0.1 + met_out.L2_V = reg(status, STATUS_ADDR, 39124) * 0.1 + met_out.L3_V = reg(status, STATUS_ADDR, 39125) * 0.1 + -- Per-phase CT power (site sign: import-positive), amps derived + -- as W/V so the sign carries through — FTW's fuse bars read + -- l1_a..l3_a signed, negative meaning export on that phase. + met_out.L1_W = -i32(ct, CT_ADDR, 38816) * 0.1 + met_out.L2_W = -i32(ct, CT_ADDR, 38818) * 0.1 + met_out.L3_W = -i32(ct, CT_ADDR, 38820) * 0.1 + if met_out.L1_V > 0 then met_out.L1_A = met_out.L1_W / met_out.L1_V end + if met_out.L2_V > 0 then met_out.L2_A = met_out.L2_W / met_out.L2_V end + if met_out.L3_V > 0 then met_out.L3_A = met_out.L3_W / met_out.L3_V end end if energy then - out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 - out.total_export_Wh = u32(energy, ENERGY_ADDR, 39613) * 10 + met_out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 + met_out.total_export_Wh = u32(energy, ENERGY_ADDR, 39613) * 10 + end + host.emit("meter", met_out) + end + + -- Fault codes 39067..39069 (already in the status read): any + -- nonzero raises a device fault carrying the codes; all-zero clears. + -- Both transitions require a status block we actually read — a + -- failed read must neither raise nor clear. + if status then + local f1 = reg(status, STATUS_ADDR, 39067) or 0 + local f2 = reg(status, STATUS_ADDR, 39068) or 0 + local f3 = reg(status, STATUS_ADDR, 39069) or 0 + local faulted = (f1 ~= 0 or f2 ~= 0 or f3 ~= 0) + if faulted and fault_active ~= true then + host.set_device_fault(true, string.format( + "inverter fault codes %d/%d/%d", f1, f2, f3)) + fault_active = true + elseif not faulted and fault_active ~= false then + host.set_device_fault(false, "") + fault_active = false end - host.emit("meter", out) + -- Diagnostics for the metric browser: the values this week's + -- debugging kept needing and never had. + host.emit_metric("inverter_temp_c", + host.decode_i16(reg(status, STATUS_ADDR, 39141)) * 0.1) + host.emit_metric("foxess_inverter_state", reg(status, STATUS_ADDR, 39063) or -1) + end + host.emit_metric("foxess_rc_enabled", rc_enabled and 1 or 0) + if prev_vendor_w ~= nil then + host.emit_metric("foxess_rc_setpoint_w", prev_vendor_w) + end + + -- Keep an active setpoint alive: the vendor timeout needs a + -- refresh every poll, and the lease releases control when the EMS + -- stops commanding instead of holding a stale target forever. + local refresh_target = session_target() + if refresh_target ~= nil then + if host.millis() - rc_command_ms > RC_LEASE_MS then + host.log("info", "foxess_h3_smart: command lease expired; releasing remote control") + release_all() + else + local ok, why = apply_remote_control(refresh_target) + if not ok and why ~= nil then + -- The setpoint is no longer computable (battery stopped + -- accepting charge, PV reading lost). Holding the session + -- would freeze the last written value; native self-use is the + -- safer place to wait. + host.log("warn", "foxess_h3_smart: releasing remote control: " .. why) + release_all() + end + end + elseif rc_enabled then + -- An armed cap with nothing to do right now (curtail-only session + -- after dark): stand the RC session down but keep the cap armed + -- so first daylight re-engages it. + release_remote_control() + end + + if curtail_cap_w ~= nil then + host.emit_metric("foxess_pv_curtail_cap_w", curtail_cap_w) end return 5000 @@ -245,13 +666,160 @@ function driver_command(action, value, context) if action == "init" or action == "deinit" then return true end - -- Read-only driver: every actuation is refused. - return false + if action == "curtail" then + if not pv_curtail_enabled then + return "curtail not enabled; set supports_pv_curtail: true in the driver's config" + end + local cap = tonumber(value) + if cap == nil then + return "curtail command needs a numeric power_w" + end + curtail_cap_w = math.abs(cap) + rc_command_ms = host.millis() + local target = session_target() + if target ~= nil then + local ok, why = apply_remote_control(target) + if not ok then + curtail_cap_w = nil + return why or "remote control write failed" + end + end + return true + end + if action == "curtail_disable" then + curtail_cap_w = nil + if rc_target_w ~= nil then + apply_remote_control(rc_target_w) + else + release_remote_control() + end + return true + end + if action ~= "battery" then + return "unsupported action: " .. tostring(action) + end + local power_w = tonumber(value) + if power_w == nil then + return "battery command needs a numeric power_w" + end + -- power_w == 0 is a real setpoint (hold the battery at zero), not a + -- release. Release happens on lease expiry and driver_default_mode. + + -- Under remote control the inverter ignores its own Max SoC, so a + -- charge command into a full pack must be refused here. + if power_w > 0 and last_soc_fract ~= nil and last_soc_fract >= 0.99 then + return "battery is full; refusing forced charge" + end + rc_target_w = power_w + rc_command_ms = host.millis() + local ok, why = apply_remote_control(power_w) + if not ok then + rc_target_w = nil + return why or "remote control write failed" + end + return true end function driver_default_mode() - -- Read-only driver: the safe state is to keep reading and command nothing. + -- Safe state: the inverter's own self-use logic. Release remote + -- control; if the write cannot go through, the vendor timeout + -- reverts the inverter on its own within RC_TIMEOUT_S. + release_all() end function driver_cleanup() + release_all() +end + +-- ====================== CONTROL V2 ENTRYPOINTS ===================== +-- Called only by a control-v2 host running the signed package; the v1 +-- entrypoints above remain for local operator builds. What changes +-- under v2, all host-enforced: +-- * every command runs inside a bounded write scope, and the result +-- must PROVE itself: "applied" requires at least one acknowledged +-- write plus a readback that the host itself observed; +-- * default mode must always write the release and read it back. +-- The v1 courtesy -- skip the disable when this driver did not +-- enable the session -- cannot be proven to the host, so the +-- signed package owns register 46001 outright. Operators running +-- FoxESS-app schedule periods must not enable managed control; +-- the local v1 build keeps the courtesy. +-- * results are structured tables, not booleans; codes are stable +-- tokens the fleet can aggregate. + +local function v2_readback_setpoint() + local ok, regs = pcall(host.modbus_read, RC_POWER_ADDR, 2, "holding") + if ok and regs and regs[1] ~= nil then + return host.decode_i32_be(regs[1], regs[2]) + end + return nil +end + +function driver_command_v2(cmd) + local settled_state = rc_enabled and "controlled" or "unchanged" + local command = cmd and (cmd.command or cmd.runtime_action) + if command ~= "battery" then + return { status = "rejected", code = "undeclared_command", + message = "command not implemented: " .. tostring(command), + device_state = settled_state } + end + local power_w = cmd.inputs and tonumber(cmd.inputs.power_w) + if power_w == nil then + return { status = "rejected", code = "missing_input", + message = "battery command needs numeric inputs.power_w", + device_state = settled_state } + end + if power_w > 0 and last_soc_fract ~= nil and last_soc_fract >= 0.99 then + return { status = "rejected", code = "battery_full", + message = "battery is full; refusing forced charge", + device_state = settled_state } + end + rc_target_w = power_w + rc_command_ms = host.millis() + local ok, why = apply_remote_control(power_w) + if not ok then + rc_target_w = nil + return { status = "failed", code = "write_failed", + message = why or "remote control write failed", + device_state = "unknown" } + end + local verify = v2_readback_setpoint() + if verify == nil or prev_vendor_w == nil or verify ~= prev_vendor_w then + return { status = "failed", code = "readback_mismatch", + message = "setpoint readback " .. tostring(verify) .. + " does not match written " .. tostring(prev_vendor_w), + device_state = "unknown" } + end + return { + status = "applied", code = "ok", + message = "battery " .. power_w .. " W held as AC setpoint " .. verify .. " W", + device_state = "controlled", + evidence = { "write_ack", "readback" }, + applied = { power_w = power_w, vendor_setpoint_w = verify }, + } +end + +function driver_default_mode_v2(info) + rc_target_w = nil + curtail_cap_w = nil + prev_vendor_w = nil + rc_enabled = false + local wrote, why = checked_write(RC_ENABLE_ADDR, 0) + if not wrote then + return { status = "failed", code = "write_failed", + message = "remote-control disable not written (" .. + tostring(why) .. "); vendor timeout reverts within " .. + RC_TIMEOUT_S .. " s", + device_state = "unknown" } + end + local ok, regs = pcall(host.modbus_read, RC_ENABLE_ADDR, 1, "holding") + if not ok or not regs or regs[1] ~= 0 then + return { status = "failed", code = "readback_mismatch", + message = "remote-control enable register did not read back 0", + device_state = "unknown" } + end + return { status = "defaulted", code = "ok", + message = "remote control released; inverter in native self-use", + device_state = "default", + evidence = { "write_ack", "readback" } } end diff --git a/drivers/tests/lua_harness/host_mock.lua b/drivers/tests/lua_harness/host_mock.lua index b1fc7b1..8c08564 100644 --- a/drivers/tests/lua_harness/host_mock.lua +++ b/drivers/tests/lua_harness/host_mock.lua @@ -132,6 +132,11 @@ function host.set_model(model) host._model = model end +function host.set_rated_w(watts) + record_call("set_rated_w", watts) + host._rated_w = watts +end + function host.set_sn(serial_number) record_call("set_sn", serial_number) host._sn = serial_number diff --git a/drivers/tests/lua_harness/test_all_drivers.lua b/drivers/tests/lua_harness/test_all_drivers.lua index b8deddf..d90cb66 100644 --- a/drivers/tests/lua_harness/test_all_drivers.lua +++ b/drivers/tests/lua_harness/test_all_drivers.lua @@ -35,7 +35,6 @@ local DRIVER_SPECS = { sofar = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, growatt = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, solax = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, - foxess = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, kostal = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, kstar = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, alphaess = { protocol = "modbus", ders = {"pv", "battery", "meter"} }, diff --git a/drivers/tests/test_foxess_h3_smart.py b/drivers/tests/test_foxess_h3_smart.py index 47e40c7..2e241ac 100644 --- a/drivers/tests/test_foxess_h3_smart.py +++ b/drivers/tests/test_foxess_h3_smart.py @@ -70,6 +70,8 @@ def fixture_registers() -> str: 39237: bat_w_hi, 39238: bat_w_lo, # energy counters, u32 pairs in 0.01 kWh 39601: _i32_regs(123456)[0], 39602: _i32_regs(123456)[1], + 39605: _i32_regs(500000)[0], 39606: _i32_regs(500000)[1], + 39609: _i32_regs(400000)[0], 39610: _i32_regs(400000)[1], 39613: _i32_regs(111111)[0], 39614: _i32_regs(111111)[1], 39617: _i32_regs(654321)[0], 39618: _i32_regs(654321)[1], # BMS singles @@ -81,7 +83,8 @@ def fixture_registers() -> str: "host._modbus_registers.holding[39279] = " + _lua_list([0, 3121, 0, 3475, 0, 0, 0, 0]), "host._modbus_registers.holding[38814] = " - + _lua_list(_i32_regs(CT_RAW)), + + _lua_list(_i32_regs(CT_RAW) + _i32_regs(6310) + + _i32_regs(-2500) + _i32_regs(8000)), ] lines += [ f"host._modbus_registers.holding[{addr}] = {value}" @@ -125,12 +128,17 @@ def run_lua(body: str) -> dict[str, str]: print("BAT_A " .. bat.A) print("BAT_SOC " .. tostring(bat.SoC_nom_fract)) print("BAT_TEMP " .. tostring(bat.temperature_C)) + print("BAT_CHG_WH " .. tostring(bat.total_charge_Wh)) + print("BAT_DIS_WH " .. tostring(bat.total_discharge_Wh)) end if met then print("MET_W " .. met.W) print("MET_HZ " .. tostring(met.Hz)) print("MET_L1V " .. tostring(met.L1_V)) print("MET_IMPORT_WH " .. tostring(met.total_import_Wh)) + print("MET_L1W " .. tostring(met.L1_W)) + print("MET_L2W " .. tostring(met.L2_W)) + print("MET_L1A " .. tostring(met.L1_A)) print("MET_EXPORT_WH " .. tostring(met.total_export_Wh)) end print("MAKE " .. tostring(host._make)) @@ -174,6 +182,14 @@ def test_capture_reproduces_in_site_convention(): assert math.isclose(float(out["MET_HZ"]), 49.99, rel_tol=1e-5) assert math.isclose(float(out["MET_L1V"]), 235.2, rel_tol=1e-5) assert float(out["MET_IMPORT_WH"]) == 6543210 + # Per-phase CT: vendor export-positive flips to site import-positive, + # and amps carry the phase power's sign (negative = export). + assert math.isclose(float(out["MET_L1W"]), -631, rel_tol=1e-4) + assert math.isclose(float(out["MET_L2W"]), 250, rel_tol=1e-4) + assert float(out["MET_L1A"]) < 0 + # Battery lifetime counters ride the same energy block. + assert float(out["BAT_CHG_WH"]) == 5000000 + assert float(out["BAT_DIS_WH"]) == 4000000 assert float(out["MET_EXPORT_WH"]) == 1111110 diff --git a/drivers/tests/test_foxess_h3_smart_control.py b/drivers/tests/test_foxess_h3_smart_control.py new file mode 100644 index 0000000..8e25dd6 --- /dev/null +++ b/drivers/tests/test_foxess_h3_smart_control.py @@ -0,0 +1,158 @@ +"""The H3-Smart control v2 entrypoints must prove what they claim. + +The control v2 contract (srcfl/ftw#738/#741) makes a driver's word +checkable: a command result is a structured table, "applied" is only +believed alongside host-observed write evidence, and default mode must +write the release and read it back rather than assert it. These tests +drive `driver_command_v2` / `driver_default_mode_v2` against the mock +harness and hold the results to that contract — statuses, codes, +device_state, the evidence list, and the registers actually written. + +The v1 entrypoints stay for local operator builds (an unsigned driver +never sees a write scope), so coexistence is asserted too. +""" + +from __future__ import annotations + +from test_foxess_h3_smart import DRIVER, fixture_registers, run_lua + +PV_W = 3121 + 3475 +EFF = 0.977 + +RESULT_REPORT = """ +local function show(r) + print("STATUS " .. tostring(r.status)) + print("CODE " .. tostring(r.code)) + print("STATE " .. tostring(r.device_state)) + local ev = r.evidence or {} + print("EVIDENCE " .. table.concat(ev, ",")) + if r.applied then + print("APPLIED_POWER " .. tostring(r.applied.power_w)) + print("APPLIED_VENDOR " .. tostring(r.applied.vendor_setpoint_w)) + end +end +""" + +RC_REPORT = """ +print("RC_ENABLE " .. tostring(host._modbus_registers.holding[46001])) +print("RC_TIMEOUT " .. tostring(host._modbus_registers.holding[46002])) +print("SP_HI " .. tostring(host._modbus_registers.holding[46003])) +print("SP_LO " .. tostring(host._modbus_registers.holding[46004])) +""" + + +def drive(body: str, extra: str = "") -> dict[str, str]: + return run_lua(f""" +{fixture_registers()} +host._modbus_registers.holding[46018] = {{0, 6000}} +{extra} +dofile("{DRIVER}") +driver_init({{}}) +local ok, err = pcall(driver_poll) +if not ok then print("POLL_ERROR " .. tostring(err)) os.exit(1) end +{RESULT_REPORT} +{body} +{RC_REPORT} +""") + + +def setpoint(out: dict[str, str]) -> int: + raw = (int(out["SP_HI"]) << 16) | int(out["SP_LO"]) + return raw - (1 << 32) if raw >= (1 << 31) else raw + + +def test_v2_discharge_applies_with_evidence(): + out = drive(""" +show(driver_command_v2({ command = "battery", + inputs = { power_w = -1000 } })) +""") + assert out["STATUS"] == "applied" + assert out["CODE"] == "ok" + assert out["STATE"] == "controlled" + assert out["EVIDENCE"] == "write_ack,readback" + # AC setpoint = pv * eff - target, rounded to whole watts. + expect = round(PV_W * EFF) + 1000 + assert setpoint(out) == expect + assert out["APPLIED_POWER"] == "-1000" + assert out["APPLIED_VENDOR"] == str(expect) + assert out["RC_ENABLE"] == "1" + assert out["RC_TIMEOUT"] == "60" + + +def test_v2_charge_into_full_battery_is_rejected(): + out = drive(""" +show(driver_command_v2({ command = "battery", + inputs = { power_w = 1000 } })) +""", extra="host._modbus_registers.holding[37612] = 100") + assert out["STATUS"] == "rejected" + assert out["CODE"] == "battery_full" + assert out["STATE"] == "unchanged" + assert out["RC_ENABLE"] == "nil" # never engaged + + +def test_v2_undeclared_command_is_rejected(): + out = drive('show(driver_command_v2({ command = "flux_capacitor" }))') + assert out["STATUS"] == "rejected" + assert out["CODE"] == "undeclared_command" + + +def test_v2_missing_input_is_rejected(): + out = drive('show(driver_command_v2({ command = "battery", inputs = {} }))') + assert out["STATUS"] == "rejected" + assert out["CODE"] == "missing_input" + + +def test_v2_write_failure_is_failed_not_applied(): + """The host bindings return an error string instead of raising; a + driver that only pcall-guards its writes would report this exact + case as success.""" + out = drive(""" +host._modbus_write_error = "simulated bus failure" +show(driver_command_v2({ command = "battery", + inputs = { power_w = -1000 } })) +""") + assert out["STATUS"] == "failed" + assert out["CODE"] == "write_failed" + assert out["STATE"] == "unknown" + + +def test_v2_readback_failure_is_not_applied(): + out = drive(""" +host._modbus_read_fail_addresses[46003] = "timeout" +show(driver_command_v2({ command = "battery", + inputs = { power_w = -1000 } })) +""") + assert out["STATUS"] == "failed" + assert out["CODE"] == "readback_mismatch" + + +def test_v2_default_mode_writes_and_proves_the_release(): + out = drive(""" +driver_command_v2({ command = "battery", inputs = { power_w = -1000 } }) +show(driver_default_mode_v2({ reason = "lease_expired" })) +""") + assert out["STATUS"] == "defaulted" + assert out["STATE"] == "default" + assert out["EVIDENCE"] == "write_ack,readback" + assert out["RC_ENABLE"] == "0" + + +def test_v2_default_mode_write_failure_reports_failed(): + out = drive(""" +driver_command_v2({ command = "battery", inputs = { power_w = -1000 } }) +host._modbus_write_error = "simulated bus failure" +show(driver_default_mode_v2({ reason = "lease_expired" })) +""") + assert out["STATUS"] == "failed" + assert out["CODE"] == "write_failed" + assert out["STATE"] == "unknown" + + +def test_v1_entrypoints_still_serve_local_builds(): + out = drive(""" +local r = driver_command("battery", 0) +print("V1_RESULT " .. tostring(r)) +""") + assert out["V1_RESULT"] == "true" + assert out["RC_ENABLE"] == "1" + assert setpoint(out) == round(PV_W * EFF) diff --git a/index.yaml b/index.yaml index caa9a6b..45e0e52 100644 --- a/index.yaml +++ b/index.yaml @@ -213,24 +213,15 @@ drivers: control: true size_bytes: 14450 sha256: "8be39a1d41b18b1e12fb9b669e6fe11c93775abfdb4f41338f9dc79fe66fd2d9" - - name: "foxess" - version: "1.1.1" - tier: community - protocol: modbus - connectivity: local - ders: [pv, battery, meter] - control: false - size_bytes: 6933 - sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" - name: "foxess_h3_smart" - version: "0.1.0" + version: "0.9.0" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] - control: false - size_bytes: 8181 - sha256: "102da78fe189a62bec0224a5a22fc27279abc15bc6ed69688bbcd50865fe49f2" + control: true + size_bytes: 33379 + sha256: "e4d50a700df28bcb43d4251996b294274cb50d04e6e47d3047470200a90c1e74" - name: "fronius" version: "2.1.1" tier: core diff --git a/manifests/foxess.yaml b/manifests/foxess.yaml deleted file mode 100644 index d717b13..0000000 --- a/manifests/foxess.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: "foxess" -version: "1.1.1" -tier: community -author: "Sourceful Labs AB" -protocol: modbus -connectivity: local -ders: [pv, battery, meter] -control: false -tested_devices: - - manufacturer: "Fox ESS" - model_family: "H1 (Single-Phase Hybrid)" - variants: [H1-3.0, H1-3.7, H1-4.6, H1-5.0, H1-6.0] - regions: [] - firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" - - manufacturer: "Fox ESS" - model_family: "H3 (Three-Phase Hybrid)" - variants: [H3-5.0, H3-6.0, H3-8.0, H3-10.0, H3-12.0] - regions: [] - firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" - - manufacturer: "Fox ESS" - model_family: "H3 PRO" - variants: [H3-PRO-10.0, H3-PRO-15.0, H3-PRO-20.0] - regions: [] - firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" - - manufacturer: "Fox ESS" - model_family: "AIO-H3 (All-in-One)" - variants: [AIO-H3-3.0, AIO-H3-5.0, AIO-H3-6.0, AIO-H3-8.0, AIO-H3-10.0] - regions: [] - firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" - - manufacturer: "Fox ESS" - model_family: "KH Series" - variants: [KH-3.0, KH-3.7, KH-5.0] - regions: [] - firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" -min_host_version: "2.0.0" -size_bytes: 6933 -dkb_id: "" -sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" -signature: "" - -bytecode_sha256: "" -bytecode_signature: "" -bytecode_size: 0 -changelog: "" diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 39749a0..5d193aa 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,35 +1,35 @@ name: "foxess_h3_smart" -version: "0.1.0" +version: "0.9.0" tier: community author: "Sourceful Labs AB" protocol: modbus connectivity: local ders: [pv, battery, meter] -control: false +control: true tested_devices: - manufacturer: "Fox ESS" model_family: "1K5 (Three-Phase Hybrid)" variants: [1K5-HI-5-V1, 1K5-HI-8-V1, 1K5-HI-10-V1, 1K5-HI-12-V1, 1K5-HI-15-V1] regions: [] firmware_versions: "" - notes: "Telemetry validated on 1K5-HI-10-V1 hardware: PV string power agrees with V*A, and pv + battery + load balances the grid CT. Read-only." - min_driver_version: "0.1.0" + notes: "Telemetry and battery control validated on 1K5-HI-10-V1 hardware: PV string power agrees with V*A, pv + battery + load balances the grid CT, and battery dispatch through the remote-control block is validated in charge, discharge and hold." + min_driver_version: "0.7.0" - manufacturer: "Fox ESS" model_family: "H3-Smart" variants: [] regions: [] firmware_versions: "" notes: "Shares the 1K5 register map; not yet tested on H3-Smart hardware." - min_driver_version: "0.1.0" + min_driver_version: "0.7.0" upstream_docs: - url: "https://github.com/nathanmarlor/foxess_modbus" title: "nathanmarlor/foxess_modbus community register map (Inv.H3_SMART profile)" kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 8181 +size_bytes: 33379 dkb_id: "" -sha256: "102da78fe189a62bec0224a5a22fc27279abc15bc6ed69688bbcd50865fe49f2" +sha256: "e4d50a700df28bcb43d4251996b294274cb50d04e6e47d3047470200a90c1e74" signature: "" bytecode_sha256: "" bytecode_signature: "" diff --git a/packages/v1/foxess_h3_smart/package-source.json b/packages/v1/foxess_h3_smart/package-source.json index 92f92ee..0a43d3b 100644 --- a/packages/v1/foxess_h3_smart/package-source.json +++ b/packages/v1/foxess_h3_smart/package-source.json @@ -1,7 +1,7 @@ { "schema_version": "sourceful.driver-package-source/v1", "package_id": "com.sourceful.driver.foxess-h3-smart", - "version": "0.1.0", + "version": "0.9.0", "channel": "beta", "display_name": "FoxESS H3-Smart / 1K5", "identity": { @@ -45,10 +45,13 @@ "battery", "meter" ], - "control": [] + "control": [ + "battery" + ] }, "permissions": [ - "modbus.read" + "modbus.read", + "modbus.write" ], "telemetry": { "schema": "sourceful.telemetry/v2", @@ -71,15 +74,33 @@ } ] }, - "commands": [], - "read_only": true, + "commands": [ + { + "id": "battery", + "capability": "battery", + "runtime_action": "battery", + "inputs": [ + { + "name": "power_w", + "type": "number", + "unit": "W", + "required": true + } + ], + "description": "Battery power setpoint, translated to the vendor AC active-power register (AC = PV - target)." + } + ], + "read_only": false, "default_mode": { - "strategy": "not_applicable", - "description": "Read-only driver." + "strategy": "vendor_autonomous", + "description": "Write the remote-control disable and read it back; the inverter runs native self-use, and the vendor-side 60 s timeout reverts it even if the write cannot land.", + "entrypoint": "driver_default_mode_v2" }, "lease_policy": { - "required_for_control": false, - "expiry_action": "not_applicable" + "required_for_control": true, + "expiry_action": "return_to_default", + "heartbeat_interval_seconds": 5, + "max_duration_seconds": 60 }, "rollback": { "strategy": "install_previous_verified_package", @@ -99,14 +120,14 @@ "name": "gopher-lua", "semantics": "lua-5.1", "version": "1.1.2", - "abi": "gopher-lua-source-v1", + "abi": "gopher-lua-source-v2", "host_api": { - "profile": "sourceful.host/ftw-core/v1", - "min": 1, - "max": 1 + "profile": "sourceful.host/ftw-core/v2", + "min": 2, + "max": 2 } }, - "control_enabled": false + "control_enabled": true }, { "target": "blixt-l1", @@ -120,14 +141,14 @@ "name": "luajit", "semantics": "lua-5.1", "version": "2.1", - "abi": "mlua-0.10-luajit21-source-v1", + "abi": "mlua-0.10-luajit21-source-v2", "host_api": { - "profile": "sourceful.host/blixt-l1/v1", - "min": 1, - "max": 1 + "profile": "sourceful.host/blixt-l1/v2", + "min": 2, + "max": 2 } }, - "control_enabled": false + "control_enabled": true } ], "artifact_inputs": [ diff --git a/support-status.json b/support-status.json index 65096ba..39f986d 100644 --- a/support-status.json +++ b/support-status.json @@ -646,41 +646,13 @@ }, { "catalog_source": true, - "catalog_version": "1.1.1", - "driver_id": "foxess", - "package_id": null, - "targets": { - "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, - "hil": "not_recorded", - "historical_signed_beta_version": null, - "legacy_parity": "not_assessed", - "note": "", - "stable_package_version": null, - "target_conformance": "not_assessed" - }, - "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, - "hil": "not_recorded", - "historical_signed_beta_version": null, - "legacy_parity": "not_assessed", - "note": "", - "stable_package_version": null, - "target_conformance": "not_assessed" - } - } - }, - { - "catalog_source": true, - "catalog_version": "0.1.0", + "catalog_version": "0.9.0", "driver_id": "foxess_h3_smart", "package_id": "com.sourceful.driver.foxess-h3-smart", "targets": { "blixt-l1": { - "candidate_package_version": "0.1.0", - "control_enabled": false, + "candidate_package_version": "0.9.0", + "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", @@ -689,8 +661,8 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.1.0", - "control_enabled": false, + "candidate_package_version": "0.9.0", + "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", diff --git a/tools/generate_history.py b/tools/generate_history.py index 2cf1f0f..a5eb77c 100644 --- a/tools/generate_history.py +++ b/tools/generate_history.py @@ -149,6 +149,13 @@ def main() -> int: fresh = build() existing = load_existing() + # A driver removed from the catalog keeps its published record: the + # history is the channel's memory of bytes that ran on hardware, not + # a mirror of what the catalog currently offers. Only rewriting a + # recorded version is a mutation; carrying one forward is the point. + for driver_id, versions in existing.get("drivers", {}).items(): + if driver_id not in fresh["drivers"]: + fresh["drivers"][driver_id] = versions mutations, additions = compare(existing, fresh) if mutations: