From 644840121bcf7a5ef9d538b6ff0d52ac848db468 Mon Sep 17 00:00:00 2001 From: Leitet Date: Mon, 3 Aug 2026 19:43:38 +0200 Subject: [PATCH 01/19] =?UTF-8?q?wip(foxess=5Fh3=5Fsmart):=200.2.0=20local?= =?UTF-8?q?=20control=20build=20=E2=80=94=20hardware-validated=20on=201K5-?= =?UTF-8?q?HI-10-V1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 127 ++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index d8cffb0..47afb67 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -19,21 +19,30 @@ -- -- 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. Vendor +-- active power is discharge-positive; the site convention is +-- charge-positive, so the setpoint is negated on the way out. Two +-- dead-man's switches protect the inverter: the vendor-side timeout +-- (46002, refreshed every poll) reverts it if this driver dies, and a +-- driver-side lease releases remote control when the EMS stops sending +-- commands. driver_default_mode releases control explicitly. DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.1.0", + version = "0.2.0", host_api_min = 1, host_api_max = 1, 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.", + 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 +55,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.1.0", + version = "0.2.0", role = "inverter", requires = {}, options = {}, @@ -88,8 +97,39 @@ 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 +-- 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 +-- 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 +local last_soc_fract = nil + local function reg(regs, base, addr) return regs[addr - base + 1] end @@ -155,6 +195,42 @@ function driver_init(config) host.set_make("FoxESS") end +local function write_setpoint(site_w) + -- Vendor sign: positive = discharge. Site: positive = charge. + local vendor = -site_w + -- 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 pcall(host.write_registers, 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 + pcall(host.write, WORK_MODE_ADDR, WORK_MODE_SELF_USE) + end + if not pcall(host.write, RC_TIMEOUT_ADDR, RC_TIMEOUT_S) then return false end + if not pcall(host.write, RC_ENABLE_ADDR, 1) then return false end + rc_enabled = true + end + local ok = write_setpoint(site_w) + return ok +end + +local function release_remote_control() + rc_target_w = nil + if rc_enabled then + rc_enabled = false + pcall(host.write, RC_ENABLE_ADDR, 0) + end +end + function driver_poll() if not identity_reported then report_identity() @@ -211,6 +287,7 @@ function driver_poll() local fract = soc[1] / 100 if fract >= 0 and fract <= 1 then out.SoC_nom_fract = fract + last_soc_fract = fract end end local bat_temp = read(BAT_TEMP_ADDR, 1) @@ -238,6 +315,18 @@ function driver_poll() host.emit("meter", out) 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. + if rc_target_w ~= nil then + if host.millis() - rc_command_ms > RC_LEASE_MS then + host.log("info", "foxess_h3_smart: battery command lease expired; releasing remote control") + release_remote_control() + else + apply_remote_control(rc_target_w) + end + end + return 5000 end @@ -245,13 +334,37 @@ 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 ~= "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 + if power_w == 0 then + release_remote_control() + return true + end + -- 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() + if not apply_remote_control(power_w) then + return "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_remote_control() end function driver_cleanup() + release_remote_control() end From 44258880373632c8467b8a79465ed2d03ba892ed Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 08:49:57 +0200 Subject: [PATCH 02/19] fix(foxess_h3_smart): the remote-control setpoint is grid power, not 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 59 ++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index 47afb67..f9d76b2 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -21,9 +21,28 @@ -- `foxess` driver. -- -- LOCAL CONTROL BUILD (operator's own risk, not the signed channel): --- battery dispatch through the vendor remote-control block. Vendor --- active power is discharge-positive; the site convention is --- charge-positive, so the setpoint is negated on the way out. Two +-- battery dispatch through the vendor remote-control block. +-- +-- The remote-control setpoint (46003/46004) is a GRID active-power +-- setpoint, not a battery-power setpoint. Hardware proof, 2026-08-05: +-- with the battery charging on PV surplus and the meter at -4 W, a +-- "charge 500 W" command written straight through made the site import +-- 590 W and the battery charge ~590 W *above* the surplus. The +-- inverter had obeyed exactly what was asked of it: import 500 W. +-- Discharge hid this for two days, because for discharge both readings +-- move the grid the same way and the host's closed loop converged +-- anyway. +-- +-- So a battery target must be translated. Solving +-- grid = load + battery + pv (site convention, all signed) +-- for the grid setpoint that yields the requested battery power, with +-- load and pv cancelling out, leaves a form that needs no load +-- measurement at all: +-- desired_grid = grid_now + (battery_target - battery_now) +-- and the vendor register is import-negative, so it receives +-- -desired_grid. Each poll recomputes this from fresh readings, so PV +-- and load drift correct themselves on the next tick rather than +-- integrating. Two -- dead-man's switches protect the inverter: the vendor-side timeout -- (46002, refreshed every poll) reverts it if this driver dies, and a -- driver-side lease releases remote control when the EMS stops sending @@ -33,7 +52,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.2.0", + version = "0.3.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -55,7 +74,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.2.0", + version = "0.3.0", role = "inverter", requires = {}, options = {}, @@ -129,6 +148,18 @@ local rc_enabled = false local rc_target_w = nil -- site convention: positive = charge local rc_command_ms = 0 local last_soc_fract = nil +-- Last polled site-convention readings, needed to translate a battery +-- target into the grid setpoint the inverter actually accepts. nil +-- until the first successful poll of each; a command that cannot be +-- translated is refused rather than guessed. +local last_grid_w = nil +local last_bat_w = nil + +-- Sanity bound on the computed grid setpoint. The translation is a +-- subtraction of two live readings, so one bad telemetry sample could +-- otherwise ask the inverter for something absurd. Comfortably above +-- this hardware's ~10 kW rating in both directions. +local MAX_SETPOINT_W = 15000 local function reg(regs, base, addr) return regs[addr - base + 1] @@ -195,9 +226,19 @@ function driver_init(config) host.set_make("FoxESS") end -local function write_setpoint(site_w) - -- Vendor sign: positive = discharge. Site: positive = charge. - local vendor = -site_w +local function write_setpoint(battery_target_w) + -- Translate a battery target into the grid setpoint that produces it + -- under the conditions this driver last measured. See the header. + if last_grid_w == nil or last_bat_w == nil then + return false + end + local desired_grid = last_grid_w + (battery_target_w - last_bat_w) + local vendor = -desired_grid + if vendor > MAX_SETPOINT_W then + vendor = MAX_SETPOINT_W + elseif vendor < -MAX_SETPOINT_W then + vendor = -MAX_SETPOINT_W + end -- 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 @@ -294,6 +335,7 @@ function driver_poll() if bat_temp then out.temperature_C = host.decode_i16(bat_temp[1]) * 0.1 end + last_bat_w = out.W host.emit("battery", out) end @@ -312,6 +354,7 @@ function driver_poll() out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 out.total_export_Wh = u32(energy, ENERGY_ADDR, 39613) * 10 end + last_grid_w = out.W host.emit("meter", out) end From 6a2792ea8fc474c772877ccf0bc93c3e58b9b401 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 08:59:55 +0200 Subject: [PATCH 03/19] Revert "fix(foxess_h3_smart): the remote-control setpoint is grid power, not battery power" This reverts commit cdd1991aeb5500fedc9e700f9436ef91f8f84450. Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 59 +++++---------------------------- 1 file changed, 8 insertions(+), 51 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index f9d76b2..47afb67 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -21,28 +21,9 @@ -- `foxess` driver. -- -- LOCAL CONTROL BUILD (operator's own risk, not the signed channel): --- battery dispatch through the vendor remote-control block. --- --- The remote-control setpoint (46003/46004) is a GRID active-power --- setpoint, not a battery-power setpoint. Hardware proof, 2026-08-05: --- with the battery charging on PV surplus and the meter at -4 W, a --- "charge 500 W" command written straight through made the site import --- 590 W and the battery charge ~590 W *above* the surplus. The --- inverter had obeyed exactly what was asked of it: import 500 W. --- Discharge hid this for two days, because for discharge both readings --- move the grid the same way and the host's closed loop converged --- anyway. --- --- So a battery target must be translated. Solving --- grid = load + battery + pv (site convention, all signed) --- for the grid setpoint that yields the requested battery power, with --- load and pv cancelling out, leaves a form that needs no load --- measurement at all: --- desired_grid = grid_now + (battery_target - battery_now) --- and the vendor register is import-negative, so it receives --- -desired_grid. Each poll recomputes this from fresh readings, so PV --- and load drift correct themselves on the next tick rather than --- integrating. Two +-- battery dispatch through the vendor remote-control block. Vendor +-- active power is discharge-positive; the site convention is +-- charge-positive, so the setpoint is negated on the way out. Two -- dead-man's switches protect the inverter: the vendor-side timeout -- (46002, refreshed every poll) reverts it if this driver dies, and a -- driver-side lease releases remote control when the EMS stops sending @@ -52,7 +33,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.3.0", + version = "0.2.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -74,7 +55,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.3.0", + version = "0.2.0", role = "inverter", requires = {}, options = {}, @@ -148,18 +129,6 @@ local rc_enabled = false local rc_target_w = nil -- site convention: positive = charge local rc_command_ms = 0 local last_soc_fract = nil --- Last polled site-convention readings, needed to translate a battery --- target into the grid setpoint the inverter actually accepts. nil --- until the first successful poll of each; a command that cannot be --- translated is refused rather than guessed. -local last_grid_w = nil -local last_bat_w = nil - --- Sanity bound on the computed grid setpoint. The translation is a --- subtraction of two live readings, so one bad telemetry sample could --- otherwise ask the inverter for something absurd. Comfortably above --- this hardware's ~10 kW rating in both directions. -local MAX_SETPOINT_W = 15000 local function reg(regs, base, addr) return regs[addr - base + 1] @@ -226,19 +195,9 @@ function driver_init(config) host.set_make("FoxESS") end -local function write_setpoint(battery_target_w) - -- Translate a battery target into the grid setpoint that produces it - -- under the conditions this driver last measured. See the header. - if last_grid_w == nil or last_bat_w == nil then - return false - end - local desired_grid = last_grid_w + (battery_target_w - last_bat_w) - local vendor = -desired_grid - if vendor > MAX_SETPOINT_W then - vendor = MAX_SETPOINT_W - elseif vendor < -MAX_SETPOINT_W then - vendor = -MAX_SETPOINT_W - end +local function write_setpoint(site_w) + -- Vendor sign: positive = discharge. Site: positive = charge. + local vendor = -site_w -- 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 @@ -335,7 +294,6 @@ function driver_poll() if bat_temp then out.temperature_C = host.decode_i16(bat_temp[1]) * 0.1 end - last_bat_w = out.W host.emit("battery", out) end @@ -354,7 +312,6 @@ function driver_poll() out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 out.total_export_Wh = u32(energy, ENERGY_ADDR, 39613) * 10 end - last_grid_w = out.W host.emit("meter", out) end From c5f369d0539c6c680217945a1bfd1f512fa96a80 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 09:06:17 +0200 Subject: [PATCH 04/19] fix(foxess_h3_smart): the setpoint is inverter AC power, not battery 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 55 ++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index 47afb67..82f0a2a 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -21,9 +21,29 @@ -- `foxess` driver. -- -- LOCAL CONTROL BUILD (operator's own risk, not the signed channel): --- battery dispatch through the vendor remote-control block. Vendor --- active power is discharge-positive; the site convention is --- charge-positive, so the setpoint is negated on the way out. Two +-- battery dispatch through the vendor remote-control block. +-- +-- 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 it has -- +-- curtailing PV, charging, or discharging -- bounded by what the +-- battery can do at that moment. +-- +-- Proved on hardware 2026-08-05 by an operator watching the roof: +-- with a full battery in full sun, a "discharge 1000 W" command sent +-- as a bare +500 made the inverter CURTAIL PV from 3191 W to ~600 W +-- rather than discharge. It had done exactly as asked -- put 500 W on +-- the AC side -- and with a full battery, throttling PV was the only +-- way. The same model explains a 12-hour grid-import runaway (write +-- -5000, inverter imports until the battery's charge ceiling) 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. +-- +-- So a battery target must be translated: +-- inverter_ac = pv_now - battery_target (both magnitudes) +-- PV is read fresh each poll. When PV is currently curtailed the first +-- setpoint under-reaches, the inverter un-curtails, and the next poll +-- corrects -- converging in a few ticks rather than integrating. Two -- dead-man's switches protect the inverter: the vendor-side timeout -- (46002, refreshed every poll) reverts it if this driver dies, and a -- driver-side lease releases remote control when the EMS stops sending @@ -33,7 +53,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.2.0", + version = "0.4.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -55,7 +75,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.2.0", + version = "0.4.0", role = "inverter", requires = {}, options = {}, @@ -129,6 +149,15 @@ local rc_enabled = false local rc_target_w = nil -- site convention: positive = charge local rc_command_ms = 0 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 + +-- 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] @@ -195,9 +224,18 @@ function driver_init(config) host.set_make("FoxESS") end -local function write_setpoint(site_w) - -- Vendor sign: positive = discharge. Site: positive = charge. - local vendor = -site_w +local function write_setpoint(battery_target_w) + -- inverter AC (export-positive) = pv - battery_target. Charging the + -- battery takes power off the AC side; discharging adds to it. + if last_pv_w == nil then + return false + end + local vendor = last_pv_w - battery_target_w + if vendor > MAX_SETPOINT_W then + vendor = MAX_SETPOINT_W + elseif vendor < -MAX_SETPOINT_W then + vendor = -MAX_SETPOINT_W + end -- 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 @@ -272,6 +310,7 @@ function driver_poll() if energy then out.total_generation_Wh = u32(energy, ENERGY_ADDR, 39601) * 10 end + last_pv_w = pv_w host.emit("pv", out) end From 205855d0b956cc3b5f83fcc406a51f8497a8a91c Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 09:45:15 +0200 Subject: [PATCH 05/19] feat(foxess_h3_smart): guarded charge path, ported from the reference 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 194 ++++++++++++++++++++++++++------ 1 file changed, 160 insertions(+), 34 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index 82f0a2a..045fb9c 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -23,37 +23,89 @@ -- 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 it has -- --- curtailing PV, charging, or discharging -- bounded by what the --- battery can do at that moment. +-- 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. -- --- Proved on hardware 2026-08-05 by an operator watching the roof: --- with a full battery in full sun, a "discharge 1000 W" command sent --- as a bare +500 made the inverter CURTAIL PV from 3191 W to ~600 W --- rather than discharge. It had done exactly as asked -- put 500 W on --- the AC side -- and with a full battery, throttling PV was the only --- way. The same model explains a 12-hour grid-import runaway (write --- -5000, inverter imports until the battery's charge ceiling) 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. +-- 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). -- --- So a battery target must be translated: --- inverter_ac = pv_now - battery_target (both magnitudes) --- PV is read fresh each poll. When PV is currently curtailed the first --- setpoint under-reaches, the inverter un-curtails, and the next poll --- corrects -- converging in a few ticks rather than integrating. Two --- dead-man's switches protect the inverter: the vendor-side timeout --- (46002, refreshed every poll) reverts it if this driver dies, and a --- driver-side lease releases remote control when the EMS stops sending --- commands. driver_default_mode releases control explicitly. - DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.4.0", + version = "0.5.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -75,7 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.4.0", + version = "0.5.0", role = "inverter", requires = {}, options = {}, @@ -135,6 +187,13 @@ local WORK_MODE_SELF_USE = 1 -- 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 driver-side lease: without a fresh battery command inside this -- window, release remote control rather than keep refreshing a stale -- setpoint forever. @@ -153,6 +212,10 @@ local last_soc_fract = nil -- 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 @@ -224,18 +287,66 @@ function driver_init(config) host.set_make("FoxESS") 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: PV at max, battery fills the difference. + if last_pv_w == nil then + return nil, "no PV reading yet" + end + return last_pv_w - 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 - p_eff + end + return -p_eff +end + local function write_setpoint(battery_target_w) - -- inverter AC (export-positive) = pv - battery_target. Charging the - -- battery takes power off the AC side; discharging adds to it. - if last_pv_w == nil then - return false + local vendor, why = compute_vendor(battery_target_w) + if vendor == nil then + return false, why end - local vendor = last_pv_w - battery_target_w 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 @@ -257,12 +368,12 @@ local function apply_remote_control(site_w) if not pcall(host.write, RC_ENABLE_ADDR, 1) then return false end rc_enabled = true end - local ok = write_setpoint(site_w) - return ok + 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 pcall(host.write, RC_ENABLE_ADDR, 0) @@ -311,6 +422,11 @@ function driver_poll() out.total_generation_Wh = u32(energy, ENERGY_ADDR, 39601) * 10 end 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", out) end @@ -362,7 +478,15 @@ function driver_poll() host.log("info", "foxess_h3_smart: battery command lease expired; releasing remote control") release_remote_control() else - apply_remote_control(rc_target_w) + local ok, why = apply_remote_control(rc_target_w) + 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_remote_control() + end end end @@ -391,8 +515,10 @@ function driver_command(action, value, context) end rc_target_w = power_w rc_command_ms = host.millis() - if not apply_remote_control(power_w) then - return "remote control write failed" + 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 From c7cd55a42eb0721b9f46f7996dceb167d27bbe57 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 10:46:00 +0200 Subject: [PATCH 06/19] fix(foxess_h3_smart): a zero command holds the battery at zero, it does 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index 045fb9c..cf47b08 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -105,7 +105,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.5.0", + version = "0.5.1", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -127,7 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.5.0", + version = "0.5.1", role = "inverter", requires = {}, options = {}, @@ -305,8 +305,15 @@ end -- 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: PV at max, battery fills the difference. + 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 @@ -504,10 +511,9 @@ function driver_command(action, value, context) if power_w == nil then return "battery command needs a numeric power_w" end - if power_w == 0 then - release_remote_control() - return true - 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 From 6f570271a91c9a8e2fd1a3cb18b21e292c97c468 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 10:57:35 +0200 Subject: [PATCH 07/19] chore(foxess_h3_smart): sync manifest and declare the package as control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Leitet --- SUPPORT_STATUS.md | 4 +- devices.yaml | 4 +- index.yaml | 6 +-- manifests/foxess_h3_smart.yaml | 10 ++--- .../v1/foxess_h3_smart/package-source.json | 43 ++++++++++++++----- support-status.json | 6 +-- 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index df8c422..79fba88 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -52,8 +52,8 @@ Catalog source is not proof that a target can install or run a driver. | 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.5.1 | ftw-core | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | no | +| foxess_h3_smart | 0.5.1 | blixt-l1 | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | no | | 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 925262b..03f0edb 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,7 +393,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.1.0" + version: "0.5.1" ders: [pv, battery, meter] control: false firmware_versions: "" @@ -448,7 +448,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.1.0" + version: "0.5.1" ders: [pv, battery, meter] control: false firmware_versions: "" diff --git a/index.yaml b/index.yaml index e0a926d..160733b 100644 --- a/index.yaml +++ b/index.yaml @@ -223,14 +223,14 @@ drivers: size_bytes: 6933 sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" - name: "foxess_h3_smart" - version: "0.1.0" + version: "0.5.1" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: false - size_bytes: 8181 - sha256: "102da78fe189a62bec0224a5a22fc27279abc15bc6ed69688bbcd50865fe49f2" + size_bytes: 21080 + sha256: "6ee467d19346dd621ea12cd6499ed7ecbb7135f879fabb8c71ecf803deb077a1" - name: "fronius" version: "2.1.1" tier: core diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 39749a0..ad7e6c9 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,5 +1,5 @@ name: "foxess_h3_smart" -version: "0.1.0" +version: "0.5.1" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -13,23 +13,23 @@ tested_devices: 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" + min_driver_version: "0.5.1" - 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.5.1" 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: 21080 dkb_id: "" -sha256: "102da78fe189a62bec0224a5a22fc27279abc15bc6ed69688bbcd50865fe49f2" +sha256: "6ee467d19346dd621ea12cd6499ed7ecbb7135f879fabb8c71ecf803deb077a1" 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..cc2b552 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.5.1", "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": "Release the vendor remote-control session; the inverter reverts to native self-use within its 60 s timeout.", + "entrypoint": "driver_default_mode" }, "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", @@ -106,7 +127,7 @@ "max": 1 } }, - "control_enabled": false + "control_enabled": true }, { "target": "blixt-l1", @@ -127,7 +148,7 @@ "max": 1 } }, - "control_enabled": false + "control_enabled": true } ], "artifact_inputs": [ diff --git a/support-status.json b/support-status.json index 4d27384..1838654 100644 --- a/support-status.json +++ b/support-status.json @@ -674,12 +674,12 @@ }, { "catalog_source": true, - "catalog_version": "0.1.0", + "catalog_version": "0.5.1", "driver_id": "foxess_h3_smart", "package_id": "com.sourceful.driver.foxess-h3-smart", "targets": { "blixt-l1": { - "candidate_package_version": "0.1.0", + "candidate_package_version": "0.5.1", "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, @@ -689,7 +689,7 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.1.0", + "candidate_package_version": "0.5.1", "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, From d516d565b82a93ac77d94361d97bd1c046768a32 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 10:59:14 +0200 Subject: [PATCH 08/19] chore(foxess_h3_smart): manifest control flag + regenerate derived catalogs Co-Authored-By: Claude Fable 5 Signed-off-by: Leitet --- SUPPORT_STATUS.md | 4 ++-- devices.yaml | 4 ++-- index.yaml | 2 +- manifests/foxess_h3_smart.yaml | 2 +- support-status.json | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 79fba88..1167708 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -52,8 +52,8 @@ Catalog source is not proof that a target can install or run a driver. | 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.5.1 | ftw-core | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | no | -| foxess_h3_smart | 0.5.1 | blixt-l1 | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | no | +| foxess_h3_smart | 0.5.1 | ftw-core | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.5.1 | blixt-l1 | not_assessed | 0.5.1 | — | 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 03f0edb..d11c735 100644 --- a/devices.yaml +++ b/devices.yaml @@ -395,7 +395,7 @@ manufacturers: driver: "foxess_h3_smart" version: "0.5.1" ders: [pv, battery, meter] - control: false + control: true 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)" @@ -450,7 +450,7 @@ manufacturers: driver: "foxess_h3_smart" version: "0.5.1" 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" diff --git a/index.yaml b/index.yaml index 160733b..5a572a0 100644 --- a/index.yaml +++ b/index.yaml @@ -228,7 +228,7 @@ drivers: protocol: modbus connectivity: local ders: [pv, battery, meter] - control: false + control: true size_bytes: 21080 sha256: "6ee467d19346dd621ea12cd6499ed7ecbb7135f879fabb8c71ecf803deb077a1" - name: "fronius" diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index ad7e6c9..8e02596 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -5,7 +5,7 @@ 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)" diff --git a/support-status.json b/support-status.json index 1838654..206bb89 100644 --- a/support-status.json +++ b/support-status.json @@ -680,7 +680,7 @@ "targets": { "blixt-l1": { "candidate_package_version": "0.5.1", - "control_enabled": false, + "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", @@ -690,7 +690,7 @@ }, "ftw-core": { "candidate_package_version": "0.5.1", - "control_enabled": false, + "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", From 35ee43ce753e2672042b8148a40882de1f77e86f Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 13:48:15 +0200 Subject: [PATCH 09/19] feat(foxess_h3_smart): per-phase meter power and amps for the fuse bars 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index cf47b08..c47b883 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -105,7 +105,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.5.1", + version = "0.6.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -127,7 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.5.1", + version = "0.6.0", role = "inverter", requires = {}, options = {}, @@ -138,6 +138,8 @@ DRIVER_MANIFEST = { "battery.SoC_nom_fract", "battery.temperature_C", "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" }, @@ -158,9 +160,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 @@ -469,6 +473,15 @@ function driver_poll() 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 + -- 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. + out.L1_W = -i32(ct, CT_ADDR, 38816) * 0.1 + out.L2_W = -i32(ct, CT_ADDR, 38818) * 0.1 + out.L3_W = -i32(ct, CT_ADDR, 38820) * 0.1 + if out.L1_V > 0 then out.L1_A = out.L1_W / out.L1_V end + if out.L2_V > 0 then out.L2_A = out.L2_W / out.L2_V end + if out.L3_V > 0 then out.L3_A = out.L3_W / out.L3_V end end if energy then out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 From c67f3b10b4a7e951036bd43d743adbb6a1f6d746 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 14:19:08 +0200 Subject: [PATCH 10/19] feat(foxess_h3_smart): close the maturity gap with the sungrow driver 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-), 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 Signed-off-by: Leitet --- SUPPORT_STATUS.md | 4 +- devices.yaml | 4 +- drivers/lua/foxess_h3_smart.lua | 55 ++++++++++++++++++- drivers/tests/lua_harness/host_mock.lua | 5 ++ index.yaml | 6 +- manifests/foxess_h3_smart.yaml | 10 ++-- .../v1/foxess_h3_smart/package-source.json | 2 +- support-status.json | 6 +- 8 files changed, 74 insertions(+), 18 deletions(-) diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 1167708..13ec86c 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -52,8 +52,8 @@ Catalog source is not proof that a target can install or run a driver. | 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.5.1 | ftw-core | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | yes | -| foxess_h3_smart | 0.5.1 | blixt-l1 | not_assessed | 0.5.1 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.7.0 | ftw-core | not_assessed | 0.7.0 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.7.0 | blixt-l1 | not_assessed | 0.7.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 d11c735..af6e44b 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,7 +393,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.5.1" + version: "0.7.0" ders: [pv, battery, meter] control: true firmware_versions: "" @@ -448,7 +448,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.5.1" + version: "0.7.0" ders: [pv, battery, meter] control: true firmware_versions: "" diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index c47b883..d2d3f1f 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -105,7 +105,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.6.0", + version = "0.7.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -127,7 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.6.0", + version = "0.7.0", role = "inverter", requires = {}, options = {}, @@ -136,6 +136,7 @@ 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", @@ -204,6 +205,10 @@ local PV_VOLTS_DAYLIGHT = 70 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 @@ -280,6 +285,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") @@ -429,6 +440,11 @@ function driver_poll() local out = {} out.W = -pv_w out.mppts = mppts + if rated_w then + out.rated_w = rated_w + end + -- Inverter heatsink temperature rides the pv stream, sungrow-style. + 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 end @@ -460,6 +476,13 @@ function driver_poll() if bat_temp then 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 + out.total_charge_Wh = u32(energy, ENERGY_ADDR, 39605) * 10 + out.total_discharge_Wh = u32(energy, ENERGY_ADDR, 39609) * 10 + end host.emit("battery", out) end @@ -490,6 +513,34 @@ function driver_poll() host.emit("meter", 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 + -- 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. 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/index.yaml b/index.yaml index 5a572a0..3c1b9ad 100644 --- a/index.yaml +++ b/index.yaml @@ -223,14 +223,14 @@ drivers: size_bytes: 6933 sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" - name: "foxess_h3_smart" - version: "0.5.1" + version: "0.7.0" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: true - size_bytes: 21080 - sha256: "6ee467d19346dd621ea12cd6499ed7ecbb7135f879fabb8c71ecf803deb077a1" + size_bytes: 24040 + sha256: "fe4dcd1ac8c03b33fc2f423ba3a343c2a1567a71a2cfd446d1f392801de9d29e" - name: "fronius" version: "2.1.1" tier: core diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 8e02596..87ceef2 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,5 +1,5 @@ name: "foxess_h3_smart" -version: "0.5.1" +version: "0.7.0" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -13,23 +13,23 @@ tested_devices: 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.5.1" + 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.5.1" + 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: 21080 +size_bytes: 24040 dkb_id: "" -sha256: "6ee467d19346dd621ea12cd6499ed7ecbb7135f879fabb8c71ecf803deb077a1" +sha256: "fe4dcd1ac8c03b33fc2f423ba3a343c2a1567a71a2cfd446d1f392801de9d29e" 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 cc2b552..e016cce 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.5.1", + "version": "0.7.0", "channel": "beta", "display_name": "FoxESS H3-Smart / 1K5", "identity": { diff --git a/support-status.json b/support-status.json index 206bb89..e9afeed 100644 --- a/support-status.json +++ b/support-status.json @@ -674,12 +674,12 @@ }, { "catalog_source": true, - "catalog_version": "0.5.1", + "catalog_version": "0.7.0", "driver_id": "foxess_h3_smart", "package_id": "com.sourceful.driver.foxess-h3-smart", "targets": { "blixt-l1": { - "candidate_package_version": "0.5.1", + "candidate_package_version": "0.7.0", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, @@ -689,7 +689,7 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.5.1", + "candidate_package_version": "0.7.0", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, From 6a3b502fe941e4cdf54cf3d9fb25f201dfeb78e9 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 14:23:05 +0200 Subject: [PATCH 11/19] fix(foxess_h3_smart): distinct emit variables and updated fixtures for 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 62 +++++++++++++-------------- drivers/tests/test_foxess_h3_smart.py | 18 +++++++- index.yaml | 4 +- manifests/foxess_h3_smart.yaml | 4 +- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index d2d3f1f..85e301d 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -437,16 +437,16 @@ 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 - out.rated_w = rated_w + pv_out.rated_w = rated_w end -- Inverter heatsink temperature rides the pv stream, sungrow-style. - out.temp_c = host.decode_i16(reg(status, STATUS_ADDR, 39141)) * 0.1 + 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 last_pv_w = pv_w local volts = 0 @@ -454,63 +454,63 @@ function driver_poll() if mppts[s].V > volts then volts = mppts[s].V end end last_pv_volts = volts - host.emit("pv", out) + 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 - out.total_charge_Wh = u32(energy, ENERGY_ADDR, 39605) * 10 - out.total_discharge_Wh = u32(energy, ENERGY_ADDR, 39609) * 10 + 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. - out.L1_W = -i32(ct, CT_ADDR, 38816) * 0.1 - out.L2_W = -i32(ct, CT_ADDR, 38818) * 0.1 - out.L3_W = -i32(ct, CT_ADDR, 38820) * 0.1 - if out.L1_V > 0 then out.L1_A = out.L1_W / out.L1_V end - if out.L2_V > 0 then out.L2_A = out.L2_W / out.L2_V end - if out.L3_V > 0 then out.L3_A = out.L3_W / out.L3_V end + 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", out) + host.emit("meter", met_out) end -- Fault codes 39067..39069 (already in the status read): any 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/index.yaml b/index.yaml index 3c1b9ad..33a36b9 100644 --- a/index.yaml +++ b/index.yaml @@ -229,8 +229,8 @@ drivers: connectivity: local ders: [pv, battery, meter] control: true - size_bytes: 24040 - sha256: "fe4dcd1ac8c03b33fc2f423ba3a343c2a1567a71a2cfd446d1f392801de9d29e" + size_bytes: 24193 + sha256: "e80934e8d913543aa55e5b8cf61558a1f1eb669a3f4cd5bd6f9320d7da45e313" - name: "fronius" version: "2.1.1" tier: core diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 87ceef2..6f4bb93 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -27,9 +27,9 @@ upstream_docs: kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 24040 +size_bytes: 24193 dkb_id: "" -sha256: "fe4dcd1ac8c03b33fc2f423ba3a343c2a1567a71a2cfd446d1f392801de9d29e" +sha256: "e80934e8d913543aa55e5b8cf61558a1f1eb669a3f4cd5bd6f9320d7da45e313" signature: "" bytecode_sha256: "" bytecode_signature: "" From 4f621914d9d3ef152274df703ccc28ff9944e903 Mon Sep 17 00:00:00 2001 From: Leitet Date: Wed, 5 Aug 2026 16:59:17 +0200 Subject: [PATCH 12/19] fix(foxess_h3_smart): derate PV to AC-achievable in the setpoint translation 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 16 ++++++++++++---- manifests/foxess_h3_smart.yaml | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index 85e301d..ff65f12 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -105,7 +105,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.7.0", + version = "0.7.1", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -127,7 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.7.0", + version = "0.7.1", role = "inverter", requires = {}, options = {}, @@ -199,6 +199,14 @@ local BAT_CHARGE_LIMIT_ADDR = 46018 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 ~3.5% less after +-- conversion. Feeding raw DC PV into the AC setpoint demands more than +-- PV can deliver and the inverter covers the gap from the battery — a +-- steady ~-70 W drain at hold-zero that the site owner spotted +-- (residual/PV ≈ 3.3% across hold samples, 2026-08-05). Derate PV to +-- its AC-achievable value; recalibrate here if panels or firmware +-- change the ratio. +local PV_AC_EFF = 0.965 -- The driver-side lease: without a fresh battery command inside this -- window, release remote control rather than keep refreshing a stale -- setpoint forever. @@ -332,7 +340,7 @@ local function compute_vendor(battery_target_w) if last_pv_w == nil then return nil, "no PV reading yet" end - return last_pv_w - battery_target_w + 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() @@ -348,7 +356,7 @@ local function compute_vendor(battery_target_w) if last_pv_w == nil then return nil, "no PV reading yet" end - return last_pv_w - p_eff + return last_pv_w * PV_AC_EFF - p_eff end return -p_eff end diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 6f4bb93..56e11ad 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -27,9 +27,9 @@ upstream_docs: kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 24193 +size_bytes: 24678 dkb_id: "" -sha256: "e80934e8d913543aa55e5b8cf61558a1f1eb669a3f4cd5bd6f9320d7da45e313" +sha256: "fb8874a1c617e1257a2afdb93af4359b850b6689b608530cab5b0040c2b23267" signature: "" bytecode_sha256: "" bytecode_signature: "" From 831705ae8db3d37e7c78128176547fdf9725564e Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 09:14:15 +0200 Subject: [PATCH 13/19] fix(foxess_h3_smart): calibrate PV_AC_EFF from two hardware points 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 Signed-off-by: Leitet --- drivers/lua/foxess_h3_smart.lua | 24 ++++++++++++++---------- manifests/foxess_h3_smart.yaml | 4 ++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index ff65f12..f01e5a4 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -105,7 +105,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.7.1", + version = "0.7.2", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -127,7 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.7.1", + version = "0.7.2", role = "inverter", requires = {}, options = {}, @@ -199,14 +199,18 @@ local BAT_CHARGE_LIMIT_ADDR = 46018 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 ~3.5% less after --- conversion. Feeding raw DC PV into the AC setpoint demands more than --- PV can deliver and the inverter covers the gap from the battery — a --- steady ~-70 W drain at hold-zero that the site owner spotted --- (residual/PV ≈ 3.3% across hold samples, 2026-08-05). Derate PV to --- its AC-achievable value; recalibrate here if panels or firmware --- change the ratio. -local PV_AC_EFF = 0.965 +-- 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. diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 56e11ad..cb33581 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -27,9 +27,9 @@ upstream_docs: kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 24678 +size_bytes: 24940 dkb_id: "" -sha256: "fb8874a1c617e1257a2afdb93af4359b850b6689b608530cab5b0040c2b23267" +sha256: "04bc59fb852ee0319e6a62201bbc7922ca91979a596e30955ae83d01ca402875" signature: "" bytecode_sha256: "" bytecode_signature: "" From c224de098bfcb567e411fa950281bff2eedd9392 Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 10:12:19 +0200 Subject: [PATCH 14/19] chore(foxess_h3_smart): align manifest and package versions at 0.7.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Leitet --- CHANGELOG.md | 1 + manifests/foxess_h3_smart.yaml | 4 ++-- packages/v1/foxess_h3_smart/package-source.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdceb8..b8ffe62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] ### Added +- **`foxess_h3_smart` 0.7.2** — 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. 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 - **`foxess_h3_smart` 0.1.0** — Fox ESS inverters on the H3-Smart register map: the 1K5-HI three-phase hybrid series (model strings like `1K5-HI-10-V1`) and the H3-Smart family that shares the map. The existing `foxess` driver speaks the H1/H3 11000-range map, and a 1K5 answers none of it — this hardware replies to an unknown register with silence rather than a Modbus exception, so the wrong map does not even fail loudly. Registers follow nathanmarlor/foxess_modbus (`Inv.H3_SMART`, recorded in `upstream_docs`); telemetry was validated against 1K5-HI-10-V1 hardware — PV string power agrees with V×A within 0.5%, and pv + battery + load balances the grid CT within inverter losses. Read-only. The BMS block (37609..37620) only answers single-register reads and is read that way; every block gives up after three consecutive failures rather than paying a failed read on every poll. `drivers/tests/test_foxess_h3_smart.py` replays the hardware capture and holds the emitted signs and scales to the site convention. The Lua harness gained `host.set_model` and `host.decode_string` — both in the host profile, neither in the mock until a driver finally called them - **Every manifest now states where its driver talks, and what a manufacturer demands before it will talk at all** — `connectivity: local | cloud` plus an optional `setup:` list. `protocol` answered neither question: `nibe_local` and `myuplink` are both `protocol: http` and both read a NIBE heat pump, and they are opposites — one reads the pump over the LAN, the other reads NIBE's cloud. Backfilled across all 80 drivers from what the sources do: **77 local, 3 cloud** (`myuplink`, `easee_cloud`, `tibber`, the only drivers that hardcode a vendor endpoint — every other HTTP driver builds its base URL from config, and all 51 Modbus, 8 MQTT and 4 serial drivers name a vendor only as `homepage`) - `setup` is the second axis and the more interesting one, because a driver can be `local` and still locked until somebody unlocks the interface. `device_screen` (nibe_local, enabled in the pump's own installer menu — no app, no account, confirmed on an S735), `device_ui` (sonnen's JSON API and token, CTEK's `ModbusTCPEnable`), `installer` (sma_pv's "one-time installer step in the WebUI / Sunny Portal"), `vendor_portal` (the three cloud drivers), `bridge` (heishamon, opendtu ×2, esphome_dsmr ×2, tesla_vehicle's TeslaBleHttpProxy, zuidwijk_p1, and goodwe's required dongle). 17 drivers carry a recorded gate; 63 do not diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index cb33581..f8d2c69 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,5 +1,5 @@ name: "foxess_h3_smart" -version: "0.7.0" +version: "0.7.2" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -12,7 +12,7 @@ tested_devices: 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." + 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" diff --git a/packages/v1/foxess_h3_smart/package-source.json b/packages/v1/foxess_h3_smart/package-source.json index e016cce..37e6e6f 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.7.0", + "version": "0.7.2", "channel": "beta", "display_name": "FoxESS H3-Smart / 1K5", "identity": { From 48b7ac75905090e43dc0b7541659a11e3aeb0957 Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 10:16:06 +0200 Subject: [PATCH 15/19] chore(foxess_h3_smart): regenerate catalogs for 0.7.2 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 Signed-off-by: Leitet --- SUPPORT_STATUS.md | 4 ++-- devices.yaml | 6 +++--- index.yaml | 6 +++--- support-status.json | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 13ec86c..e12d040 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -52,8 +52,8 @@ Catalog source is not proof that a target can install or run a driver. | 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.7.0 | ftw-core | not_assessed | 0.7.0 | — | not_recorded | — | not_assessed | yes | -| foxess_h3_smart | 0.7.0 | blixt-l1 | not_assessed | 0.7.0 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.7.2 | ftw-core | not_assessed | 0.7.2 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.7.2 | blixt-l1 | not_assessed | 0.7.2 | — | 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 af6e44b..43516bd 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,11 +393,11 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.7.0" + version: "0.7.2" ders: [pv, battery, meter] control: true 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." + 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: "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: [] @@ -448,7 +448,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.7.0" + version: "0.7.2" ders: [pv, battery, meter] control: true firmware_versions: "" diff --git a/index.yaml b/index.yaml index 33a36b9..2b7208f 100644 --- a/index.yaml +++ b/index.yaml @@ -223,14 +223,14 @@ drivers: size_bytes: 6933 sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" - name: "foxess_h3_smart" - version: "0.7.0" + version: "0.7.2" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: true - size_bytes: 24193 - sha256: "e80934e8d913543aa55e5b8cf61558a1f1eb669a3f4cd5bd6f9320d7da45e313" + size_bytes: 24940 + sha256: "04bc59fb852ee0319e6a62201bbc7922ca91979a596e30955ae83d01ca402875" - name: "fronius" version: "2.1.1" tier: core diff --git a/support-status.json b/support-status.json index e9afeed..af5b4b3 100644 --- a/support-status.json +++ b/support-status.json @@ -674,12 +674,12 @@ }, { "catalog_source": true, - "catalog_version": "0.7.0", + "catalog_version": "0.7.2", "driver_id": "foxess_h3_smart", "package_id": "com.sourceful.driver.foxess-h3-smart", "targets": { "blixt-l1": { - "candidate_package_version": "0.7.0", + "candidate_package_version": "0.7.2", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, @@ -689,7 +689,7 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.7.0", + "candidate_package_version": "0.7.2", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, From 52582af2679d12e2a65ab905c8955cb60c3ea688 Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 12:48:43 +0200 Subject: [PATCH 16/19] feat(foxess_h3_smart): pv-curtail via the AC-setpoint ceiling (0.8.0) 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 Signed-off-by: Leitet --- CHANGELOG.md | 2 +- devices.yaml | 4 +- drivers/lua/foxess_h3_smart.lua | 116 ++++++++++++++++-- index.yaml | 6 +- manifests/foxess_h3_smart.yaml | 6 +- .../v1/foxess_h3_smart/package-source.json | 2 +- 6 files changed, 116 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a71f76..1f471a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] ### Added -- **`foxess_h3_smart` 0.7.2** — 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. 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 +- **`foxess_h3_smart` 0.8.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. 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/devices.yaml b/devices.yaml index cdc6007..310b69f 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,7 +393,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.7.2" + version: "0.8.0" ders: [pv, battery, meter] control: true firmware_versions: "" @@ -448,7 +448,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.7.2" + version: "0.8.0" ders: [pv, battery, meter] control: true firmware_versions: "" diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index f01e5a4..9b03c6f 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -101,15 +101,38 @@ -- 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.7.2", + version = "0.8.0", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, - capabilities = { "pv", "battery", "meter" }, + 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" }, @@ -127,7 +150,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.7.2", + version = "0.8.0", role = "inverter", requires = {}, options = {}, @@ -228,6 +251,11 @@ local fault_active = nil 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 @@ -312,6 +340,7 @@ 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() @@ -370,6 +399,13 @@ local function write_setpoint(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 @@ -414,6 +450,27 @@ local function release_remote_control() 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() if not identity_reported then report_identity() @@ -556,21 +613,31 @@ function driver_poll() -- 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. - if rc_target_w ~= nil then + 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: battery command lease expired; releasing remote control") - release_remote_control() + host.log("info", "foxess_h3_smart: command lease expired; releasing remote control") + release_all() else - local ok, why = apply_remote_control(rc_target_w) + 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_remote_control() + 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 @@ -580,6 +647,35 @@ function driver_command(action, value, context) if action == "init" or action == "deinit" then return true end + 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 @@ -609,9 +705,9 @@ function driver_default_mode() -- 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_remote_control() + release_all() end function driver_cleanup() - release_remote_control() + release_all() end diff --git a/index.yaml b/index.yaml index 401f362..85f72db 100644 --- a/index.yaml +++ b/index.yaml @@ -223,14 +223,14 @@ drivers: size_bytes: 6933 sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" - name: "foxess_h3_smart" - version: "0.7.2" + version: "0.8.0" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: true - size_bytes: 24940 - sha256: "04bc59fb852ee0319e6a62201bbc7922ca91979a596e30955ae83d01ca402875" + size_bytes: 28843 + sha256: "ab195e5233be1baf7e5d9fb897e984abfe02ec2b9378400e12b9108aa86915e8" - name: "fronius" version: "2.1.1" tier: core diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index f8d2c69..af1d62b 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,5 +1,5 @@ name: "foxess_h3_smart" -version: "0.7.2" +version: "0.8.0" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -27,9 +27,9 @@ upstream_docs: kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 24940 +size_bytes: 28843 dkb_id: "" -sha256: "04bc59fb852ee0319e6a62201bbc7922ca91979a596e30955ae83d01ca402875" +sha256: "ab195e5233be1baf7e5d9fb897e984abfe02ec2b9378400e12b9108aa86915e8" 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 37e6e6f..a1067ad 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.7.2", + "version": "0.8.0", "channel": "beta", "display_name": "FoxESS H3-Smart / 1K5", "identity": { From eb5021d4087ae30c9baf764c400cac3bb25dca8a Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 12:49:56 +0200 Subject: [PATCH 17/19] chore(foxess_h3_smart): regenerate support-status for 0.8.0 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 Signed-off-by: Leitet --- SUPPORT_STATUS.md | 4 ++-- support-status.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index b50fa66..b1f0245 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -52,8 +52,8 @@ Catalog source is not proof that a target can install or run a driver. | 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.7.2 | ftw-core | not_assessed | 0.7.2 | — | not_recorded | — | not_assessed | yes | -| foxess_h3_smart | 0.7.2 | blixt-l1 | not_assessed | 0.7.2 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.8.0 | ftw-core | not_assessed | 0.8.0 | — | not_recorded | — | not_assessed | yes | +| foxess_h3_smart | 0.8.0 | blixt-l1 | not_assessed | 0.8.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/support-status.json b/support-status.json index 9ad7dde..30339ea 100644 --- a/support-status.json +++ b/support-status.json @@ -674,12 +674,12 @@ }, { "catalog_source": true, - "catalog_version": "0.7.2", + "catalog_version": "0.8.0", "driver_id": "foxess_h3_smart", "package_id": "com.sourceful.driver.foxess-h3-smart", "targets": { "blixt-l1": { - "candidate_package_version": "0.7.2", + "candidate_package_version": "0.8.0", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, @@ -689,7 +689,7 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.7.2", + "candidate_package_version": "0.8.0", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, From d5a91271aa2454ac26b15f4cb86b398719e5a67d Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 14:44:54 +0200 Subject: [PATCH 18/19] feat(foxess_h3_smart): control v2 entrypoints (0.9.0) 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 Signed-off-by: Leitet --- CHANGELOG.md | 2 +- SUPPORT_STATUS.md | 4 +- devices.yaml | 4 +- drivers/lua/foxess_h3_smart.lua | 128 +++++++++++++- drivers/tests/test_foxess_h3_smart_control.py | 158 ++++++++++++++++++ index.yaml | 6 +- manifests/foxess_h3_smart.yaml | 6 +- .../v1/foxess_h3_smart/package-source.json | 22 +-- support-status.json | 6 +- 9 files changed, 303 insertions(+), 33 deletions(-) create mode 100644 drivers/tests/test_foxess_h3_smart_control.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f471a7..bec8f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] ### Added -- **`foxess_h3_smart` 0.8.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. 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 +- **`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 b1f0245..52c3b51 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -52,8 +52,8 @@ Catalog source is not proof that a target can install or run a driver. | 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.8.0 | ftw-core | not_assessed | 0.8.0 | — | not_recorded | — | not_assessed | yes | -| foxess_h3_smart | 0.8.0 | blixt-l1 | not_assessed | 0.8.0 | — | not_recorded | — | not_assessed | yes | +| 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 310b69f..31ab489 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,7 +393,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.8.0" + version: "0.9.0" ders: [pv, battery, meter] control: true firmware_versions: "" @@ -448,7 +448,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.8.0" + version: "0.9.0" ders: [pv, battery, meter] control: true firmware_versions: "" diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index 9b03c6f..5a314bc 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -128,9 +128,9 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.8.0", + version = "0.9.0", host_api_min = 1, - host_api_max = 1, + host_api_max = 2, protocols = { "modbus" }, 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.", @@ -150,7 +150,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.8.0", + version = "0.9.0", role = "inverter", requires = {}, options = {}, @@ -394,6 +394,25 @@ local function compute_vendor(battery_target_w) 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 @@ -423,7 +442,7 @@ local function write_setpoint(battery_target_w) -- stays small enough for a single-precision host. local hi = math.floor(vendor / 65536) % 65536 local lo = vendor % 65536 - return pcall(host.write_registers, RC_POWER_ADDR, { hi, lo }) + return checked_write_multi(RC_POWER_ADDR, { hi, lo }) end local function apply_remote_control(site_w) @@ -432,10 +451,10 @@ local function apply_remote_control(site_w) -- 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 - pcall(host.write, WORK_MODE_ADDR, WORK_MODE_SELF_USE) + checked_write(WORK_MODE_ADDR, WORK_MODE_SELF_USE) end - if not pcall(host.write, RC_TIMEOUT_ADDR, RC_TIMEOUT_S) then return false end - if not pcall(host.write, RC_ENABLE_ADDR, 1) then return false 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) @@ -446,7 +465,7 @@ local function release_remote_control() prev_vendor_w = nil if rc_enabled then rc_enabled = false - pcall(host.write, RC_ENABLE_ADDR, 0) + checked_write(RC_ENABLE_ADDR, 0) end end @@ -711,3 +730,96 @@ 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/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 85f72db..fa7ce08 100644 --- a/index.yaml +++ b/index.yaml @@ -223,14 +223,14 @@ drivers: size_bytes: 6933 sha256: "c998df936f95c2c183d027fbf5fc297e1c551b53b81da370c06d03f397959933" - name: "foxess_h3_smart" - version: "0.8.0" + version: "0.9.0" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: true - size_bytes: 28843 - sha256: "ab195e5233be1baf7e5d9fb897e984abfe02ec2b9378400e12b9108aa86915e8" + size_bytes: 33379 + sha256: "e4d50a700df28bcb43d4251996b294274cb50d04e6e47d3047470200a90c1e74" - name: "fronius" version: "2.1.1" tier: core diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index af1d62b..5d193aa 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,5 +1,5 @@ name: "foxess_h3_smart" -version: "0.8.0" +version: "0.9.0" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -27,9 +27,9 @@ upstream_docs: kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 28843 +size_bytes: 33379 dkb_id: "" -sha256: "ab195e5233be1baf7e5d9fb897e984abfe02ec2b9378400e12b9108aa86915e8" +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 a1067ad..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.8.0", + "version": "0.9.0", "channel": "beta", "display_name": "FoxESS H3-Smart / 1K5", "identity": { @@ -93,8 +93,8 @@ "read_only": false, "default_mode": { "strategy": "vendor_autonomous", - "description": "Release the vendor remote-control session; the inverter reverts to native self-use within its 60 s timeout.", - "entrypoint": "driver_default_mode" + "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": true, @@ -120,11 +120,11 @@ "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": true @@ -141,11 +141,11 @@ "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": true diff --git a/support-status.json b/support-status.json index 30339ea..8cbe35d 100644 --- a/support-status.json +++ b/support-status.json @@ -674,12 +674,12 @@ }, { "catalog_source": true, - "catalog_version": "0.8.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.8.0", + "candidate_package_version": "0.9.0", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, @@ -689,7 +689,7 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.8.0", + "candidate_package_version": "0.9.0", "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, From 71c2c24e1b93c056d10bac48e8e61d6ca0d729fa Mon Sep 17 00:00:00 2001 From: Leitet Date: Thu, 6 Aug 2026 15:49:27 +0200 Subject: [PATCH 19/19] chore: remove the untested foxess H1/H3 driver, keeping its record 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 Signed-off-by: Leitet --- CHANGELOG.md | 3 + SUPPORT_STATUS.md | 2 - devices.yaml | 55 ---- drivers/lua/foxess.lua | 241 ------------------ .../tests/lua_harness/test_all_drivers.lua | 1 - index.yaml | 9 - manifests/foxess.yaml | 54 ---- support-status.json | 28 -- tools/generate_history.py | 7 + 9 files changed, 10 insertions(+), 390 deletions(-) delete mode 100644 drivers/lua/foxess.lua delete mode 100644 manifests/foxess.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index bec8f28..6189c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ 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 diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 52c3b51..ff1ffc8 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -50,8 +50,6 @@ 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.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 | diff --git a/devices.yaml b/devices.yaml index 31ab489..5608a15 100644 --- a/devices.yaml +++ b/devices.yaml @@ -398,50 +398,6 @@ manufacturers: control: true firmware_versions: "" 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: "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 - firmware_versions: "" - notes: "Community driver, untested" - name: "H3-Smart" variants: [] regions: [] @@ -453,17 +409,6 @@ manufacturers: 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/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/index.yaml b/index.yaml index fa7ce08..45e0e52 100644 --- a/index.yaml +++ b/index.yaml @@ -213,15 +213,6 @@ 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.9.0" tier: community 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/support-status.json b/support-status.json index 8cbe35d..39f986d 100644 --- a/support-status.json +++ b/support-status.json @@ -644,34 +644,6 @@ } } }, - { - "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.9.0", 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: