From 644840121bcf7a5ef9d538b6ff0d52ac848db468 Mon Sep 17 00:00:00 2001 From: Leitet Date: Mon, 3 Aug 2026 19:43:38 +0200 Subject: [PATCH 01/12] =?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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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: ""