diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index df8c422..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.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.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 925262b..af6e44b 100644 --- a/devices.yaml +++ b/devices.yaml @@ -393,9 +393,9 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.1.0" + version: "0.7.0" 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)" @@ -448,9 +448,9 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.1.0" + version: "0.7.0" ders: [pv, battery, meter] - control: false + control: true firmware_versions: "" notes: "Shares the 1K5 register map; not yet tested on H3-Smart hardware." - name: "KH Series" diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index d8cffb0..ff65f12 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -19,21 +19,102 @@ -- -- The distinct H1/H3 (11000-range) register map lives in the separate -- `foxess` driver. - +-- +-- LOCAL CONTROL BUILD (operator's own risk, not the signed channel): +-- battery dispatch through the vendor remote-control block. +-- +-- ============================ SEMANTICS ============================ +-- The setpoint at 46003/46004 is the INVERTER'S AC ACTIVE POWER, +-- export-positive. It is not battery power and not a grid-meter +-- target. The inverter reaches the number by any means available -- +-- running PV, curtailing PV, charging, or discharging -- bounded by +-- what the battery accepts at that moment. +-- +-- Hardware evidence behind this model (1K5-HI-10-V1, 2026-08-03/05), +-- each once misdiagnosed before the model fell out: +-- * write -5000 ("charge 5 kW" naively): imported 4.5 kW from the +-- grid for 12 h against an idle plan -- import runs until the +-- battery's charge ceiling, and imported power DISPLACES PV +-- before adding to it; +-- * write +500 with a full battery in full sun ("discharge 500" +-- naively): the inverter CURTAILED PV 3191 W -> 600 W instead of +-- discharging -- the operator saw the array throttle, which the +-- meter data alone could not reveal; +-- * bare `vendor = -target` appeared to work for discharge on +-- 2026-08-03 only because PV was ~0 that evening; the naive form +-- is correct exactly when PV is zero. +-- The same semantics are confirmed independently by the +-- nathanmarlor/foxess_modbus remote-control implementation, whose +-- comments describe the import-displaces-PV behaviour verbatim. +-- +-- ========================== TRANSLATION ============================ +-- DISCHARGE (battery_target < 0), hardware-validated 2026-08-05 +-- (commanded -1000 in full sun: battery -1080, PV uncurtailed): +-- vendor = pv_now + |battery_target| +-- PV passes through at max; the battery fills the difference. The +-- ~8% overshoot is DC->AC conversion loss (the AC side is what we +-- set); the host's closed loop absorbs it. +-- +-- CHARGE (battery_target > 0) is NOT a formula but a guarded one: +-- imported power displaces PV first, and a naive setpoint spirals +-- (curtailed PV -> lower reading -> deeper import). Guards, in order: +-- 1. BMS ceiling: read Pwr_limit_Bat_up (46018/46019) fresh at the +-- command and on every refresh; effective charge is capped at +-- that limit minus a 200 W margin. The margin is the reference +-- implementation's finding: command right at the limit and the +-- inverter clips PV while the battery takes ~50 W less than it +-- could -- the gap is what lets PV fill in. Below a 250 W floor +-- the battery is effectively refusing charge (full, cold, BMS +-- hold): release remote control and report why, letting native +-- self-use surplus-charge instead. +-- 2. Daylight split (PV string VOLTAGE >= 70 V -- voltage says the +-- panels are awake even when power is ~0 at dawn; power says +-- nothing at night): +-- daylight: vendor = pv_now - p_eff (import only appears +-- implicitly when p_eff exceeds live PV) +-- night: vendor = -p_eff (pure import; nothing +-- to displace -- the reference does exactly this) +-- 3. Import/export crossing pause: when the computed setpoint +-- changes sign between refreshes, write one cycle of 0 W first. +-- Reference finding: crossing in one step can oscillate. +-- 4. Bounded worst case, documented deliberately: if the inverter +-- chooses to curtail PV rather than charge (seen only with a +-- full battery so far), the fresh-PV recomputation converges to +-- import-only charging capped by guard 1 -- wasteful of PV but +-- bounded; it cannot run away. If testing shows curtailment at +-- healthy SoC too, the next step is the reference's P-loop on +-- import power with battery-uptake feedback, not a bigger cap. +-- +-- Divergence from the reference, on purpose: it swaps the fallback +-- work mode per direction (FEED_IN_FIRST for discharge, BACK_UP for +-- charge) to bias the inverter's behaviour if remote control drops. +-- This driver keeps SELF_USE as the only fallback: it is the mode the +-- operator runs, it never imports to the battery and never exports +-- the battery, and a dead-man fallback should be boring. +-- +-- ============================ SAFETY =============================== +-- Two dead-man's switches: the vendor-side timeout (46002, refreshed +-- every poll; must be >= 60 s -- the master samples this block slowly +-- and a 15 s session expires unseen) and a driver-side 60 s command +-- lease. driver_default_mode releases remote control explicitly. +-- Charge is refused at SoC >= 99%: the inverter ignores its own Max +-- SoC under remote control (reference finding, and this battery +-- reached 100% under FTW charge on 2026-08-05). +-- DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.1.0", + version = "0.7.1", 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 +127,7 @@ PROTOCOL = "modbus" -- other field here. DRIVER_MANIFEST = { name = "foxess_h3_smart", - version = "0.1.0", + version = "0.7.1", role = "inverter", requires = {}, options = {}, @@ -55,8 +136,11 @@ DRIVER_MANIFEST = { "pv.W", "pv.mppts", "pv.total_generation_Wh", "battery.W", "battery.V", "battery.A", "battery.SoC_nom_fract", "battery.temperature_C", + "battery.total_charge_Wh", "battery.total_discharge_Wh", "meter.W", "meter.Hz", "meter.L1_V", "meter.L2_V", "meter.L3_V", + "meter.L1_W", "meter.L2_W", "meter.L3_W", + "meter.L1_A", "meter.L2_A", "meter.L3_A", "meter.total_import_Wh", "meter.total_export_Wh", }, static = { "make" }, @@ -77,9 +161,11 @@ local POWER_COUNT = 20 -- pv4 39285... local PV_ADDR = 39279 local PV_COUNT = 8 --- Grid CT total power, i32, 0.1 W units. Vendor sign: positive = export. +-- Grid CT power, i32 pairs in 0.1 W units. Vendor sign: positive = +-- export. Total at 38814; per-phase R/S/T at 38816/38818/38820 — the +-- site meter's per-phase data feeds FTW's fuse bars. local CT_ADDR = 38814 -local CT_COUNT = 2 +local CT_COUNT = 8 -- Energy counters, u32 pairs in 0.01 kWh: solar 39601.., feed-in -- 39613.., grid consumption 39617... local ENERGY_ADDR = 39601 @@ -88,7 +174,70 @@ local ENERGY_COUNT = 18 local SOC_ADDR = 37612 local BAT_TEMP_ADDR = 37611 +-- Remote control block (single-register writes only for enable/timeout; +-- the setpoint is one multi-register write, high word at 46003). +local RC_ENABLE_ADDR = 46001 +local RC_TIMEOUT_ADDR = 46002 +local RC_POWER_ADDR = 46003 +local WORK_MODE_ADDR = 49203 +local WORK_MODE_SELF_USE = 1 + +-- The inverter reverts to its fallback work mode when the timeout +-- expires without a refresh. Hardware-derived floor: the master +-- processor samples the remote-control block slowly, and a 15 s +-- session expired before it ever acted — writes landed, read back +-- correctly, and did nothing. The FoxESS app's own force periods use +-- these same registers with a period-length timeout. 60 s is long +-- enough for the master to act and still reverts the inverter within +-- a minute if this driver dies; the driver-side lease below is the +-- tighter of the two guards. +local RC_TIMEOUT_S = 60 +-- Battery charge ceiling register (i32 pair, high word at 46018): +-- how much the battery accepts right now, BMS included. +local BAT_CHARGE_LIMIT_ADDR = 46018 +-- See the CHARGE section of the header for all three of these. +local CHARGE_BMS_MARGIN_W = 200 +local CHARGE_BMS_FLOOR_W = 250 +local PV_VOLTS_DAYLIGHT = 70 +-- The PV reading is DC-side; the AC terminals see ~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. +local RC_LEASE_MS = 60000 + local identity_reported = false +local rated_w = nil +-- Device-fault latch: only raise/clear on a status block we actually +-- read, and only write the host state on a change (mirrors sungrow). +local fault_active = nil + +-- Remote-control state. rc_enabled tracks whether *we* enabled it: the +-- FoxESS app's own strategy periods use the same register, so a driver +-- that did not enable remote control must never write the disable. +local rc_enabled = false +local rc_target_w = nil -- site convention: positive = charge +local rc_command_ms = 0 +local last_soc_fract = nil +-- PV generation as a positive magnitude, from the last poll. The +-- setpoint translation needs it; a command that arrives before the +-- first PV reading is refused rather than guessed. +local last_pv_w = nil +-- Highest PV string voltage from the last poll: the daylight detector. +local last_pv_volts = nil +-- Last AC setpoint written, for the sign-crossing pause. +local prev_vendor_w = nil + +-- Sanity bound on the computed setpoint: the translation subtracts two +-- live values, and one bad sample should not ask this hardware for +-- something absurd. Comfortably outside its ~10 kW rating both ways. +local MAX_SETPOINT_W = 15000 local function reg(regs, base, addr) return regs[addr - base + 1] @@ -144,6 +293,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") @@ -155,6 +310,106 @@ 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, and HOLD-AT-ZERO: AC = pv - target, so target 0 pins + -- the battery at 0 with all PV flowing to house + grid. Zero must + -- be an enforced setpoint, not a release: this inverter's + -- uncommanded state is self-use, which absorbs the surplus into + -- the battery -- and a controller that commands 0, releases, and + -- watches native charging surge back gets a ~90 s limit cycle + -- (observed live 2026-08-05: steady 3 kW PV, battery saw-toothing + -- 250..2300 W against FTW's absorb ceiling). + if last_pv_w == nil then + return nil, "no PV reading yet" + end + return last_pv_w * PV_AC_EFF - battery_target_w + end + -- Charge: guard 1, the live BMS ceiling. + local limit = read_battery_charge_limit_w() + if limit == nil then + return nil, "battery charge limit unreadable" + end + if limit < CHARGE_BMS_FLOOR_W then + return nil, "battery is not accepting charge now" + end + local p_eff = math.min(battery_target_w, limit - CHARGE_BMS_MARGIN_W) + -- Guard 2: daylight by string voltage, not power. + if (last_pv_volts or 0) >= PV_VOLTS_DAYLIGHT then + if last_pv_w == nil then + return nil, "no PV reading yet" + end + return last_pv_w * PV_AC_EFF - p_eff + end + return -p_eff +end + +local function write_setpoint(battery_target_w) + local vendor, why = compute_vendor(battery_target_w) + if vendor == nil then + return false, why + end + if vendor > MAX_SETPOINT_W then + vendor = MAX_SETPOINT_W + elseif vendor < -MAX_SETPOINT_W then + vendor = -MAX_SETPOINT_W + end + -- Guard 3: one cycle of 0 W when crossing import/export. + if prev_vendor_w ~= nil and + ((prev_vendor_w > 0 and vendor < 0) or (prev_vendor_w < 0 and vendor > 0)) then + vendor = 0 + end + prev_vendor_w = vendor + -- Two's complement from the signed value directly. Adding 2^32 first + -- would be exact only where Lua numbers are doubles; Lua's modulo is + -- floored, so this yields the same two words while every operand + -- stays small enough for a single-precision host. + local hi = math.floor(vendor / 65536) % 65536 + local lo = vendor % 65536 + return 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 + 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) + end +end + function driver_poll() if not identity_reported then report_identity() @@ -190,52 +445,128 @@ function driver_poll() mppts[s] = nil end - local out = {} - out.W = -pv_w - out.mppts = mppts + local pv_out = {} + pv_out.W = -pv_w + pv_out.mppts = mppts + if rated_w then + pv_out.rated_w = rated_w + end + -- Inverter heatsink temperature rides the pv stream, sungrow-style. + pv_out.temp_c = host.decode_i16(reg(status, STATUS_ADDR, 39141)) * 0.1 if energy then - out.total_generation_Wh = u32(energy, ENERGY_ADDR, 39601) * 10 + pv_out.total_generation_Wh = u32(energy, ENERGY_ADDR, 39601) * 10 + end + 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 - host.emit("pv", out) + last_pv_volts = volts + host.emit("pv", pv_out) end -- ---- Battery ---- -- Vendor sign: positive = discharge. Site convention: positive = charge. if power then - local out = {} - out.W = -i32(power, POWER_ADDR, 39237) - out.V = reg(power, POWER_ADDR, 39227) * 0.1 - out.A = -i32(power, POWER_ADDR, 39228) * 0.001 + local bat_out = {} + bat_out.W = -i32(power, POWER_ADDR, 39237) + bat_out.V = reg(power, POWER_ADDR, 39227) * 0.1 + bat_out.A = -i32(power, POWER_ADDR, 39228) * 0.001 local soc = read(SOC_ADDR, 1) if soc then local fract = soc[1] / 100 if fract >= 0 and fract <= 1 then - out.SoC_nom_fract = fract + bat_out.SoC_nom_fract = fract + last_soc_fract = fract end end local bat_temp = read(BAT_TEMP_ADDR, 1) if bat_temp then - out.temperature_C = host.decode_i16(bat_temp[1]) * 0.1 + bat_out.temperature_C = host.decode_i16(bat_temp[1]) * 0.1 + end + -- Lifetime counters live in the energy block this poll already + -- read; added only when it answered (a zero would read as a reset + -- meter). + if energy then + bat_out.total_charge_Wh = u32(energy, ENERGY_ADDR, 39605) * 10 + bat_out.total_discharge_Wh = u32(energy, ENERGY_ADDR, 39609) * 10 end - host.emit("battery", out) + host.emit("battery", bat_out) end -- ---- Meter ---- -- Vendor sign: positive = export. Site convention: positive = import. if ct then - local out = {} - out.W = -i32(ct, CT_ADDR, CT_ADDR) * 0.1 + local met_out = {} + met_out.W = -i32(ct, CT_ADDR, CT_ADDR) * 0.1 if status then - out.Hz = reg(status, STATUS_ADDR, 39139) * 0.01 - out.L1_V = reg(status, STATUS_ADDR, 39123) * 0.1 - out.L2_V = reg(status, STATUS_ADDR, 39124) * 0.1 - out.L3_V = reg(status, STATUS_ADDR, 39125) * 0.1 + met_out.Hz = reg(status, STATUS_ADDR, 39139) * 0.01 + met_out.L1_V = reg(status, STATUS_ADDR, 39123) * 0.1 + met_out.L2_V = reg(status, STATUS_ADDR, 39124) * 0.1 + met_out.L3_V = reg(status, STATUS_ADDR, 39125) * 0.1 + -- Per-phase CT power (site sign: import-positive), amps derived + -- as W/V so the sign carries through — FTW's fuse bars read + -- l1_a..l3_a signed, negative meaning export on that phase. + met_out.L1_W = -i32(ct, CT_ADDR, 38816) * 0.1 + met_out.L2_W = -i32(ct, CT_ADDR, 38818) * 0.1 + met_out.L3_W = -i32(ct, CT_ADDR, 38820) * 0.1 + if met_out.L1_V > 0 then met_out.L1_A = met_out.L1_W / met_out.L1_V end + if met_out.L2_V > 0 then met_out.L2_A = met_out.L2_W / met_out.L2_V end + if met_out.L3_V > 0 then met_out.L3_A = met_out.L3_W / met_out.L3_V end end if energy then - out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 - out.total_export_Wh = u32(energy, ENERGY_ADDR, 39613) * 10 + met_out.total_import_Wh = u32(energy, ENERGY_ADDR, 39617) * 10 + met_out.total_export_Wh = u32(energy, ENERGY_ADDR, 39613) * 10 + end + host.emit("meter", met_out) + end + + -- Fault codes 39067..39069 (already in the status read): any + -- nonzero raises a device fault carrying the codes; all-zero clears. + -- Both transitions require a status block we actually read — a + -- failed read must neither raise nor clear. + if status then + local f1 = reg(status, STATUS_ADDR, 39067) or 0 + local f2 = reg(status, STATUS_ADDR, 39068) or 0 + local f3 = reg(status, STATUS_ADDR, 39069) or 0 + local faulted = (f1 ~= 0 or f2 ~= 0 or f3 ~= 0) + if faulted and fault_active ~= true then + host.set_device_fault(true, string.format( + "inverter fault codes %d/%d/%d", f1, f2, f3)) + fault_active = true + elseif not faulted and fault_active ~= false then + host.set_device_fault(false, "") + fault_active = false + end + -- 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. + 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 + 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 - host.emit("meter", out) end return 5000 @@ -245,13 +576,38 @@ 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 + -- power_w == 0 is a real setpoint (hold the battery at zero), not a + -- release. Release happens on lease expiry and driver_default_mode. + + -- Under remote control the inverter ignores its own Max SoC, so a + -- charge command into a full pack must be refused here. + if power_w > 0 and last_soc_fract ~= nil and last_soc_fract >= 0.99 then + return "battery is full; refusing forced charge" + end + rc_target_w = power_w + rc_command_ms = host.millis() + local ok, why = apply_remote_control(power_w) + if not ok then + rc_target_w = nil + return why or "remote control write failed" + end + return true end function driver_default_mode() - -- Read-only driver: the safe state is to keep reading and command nothing. + -- Safe state: the inverter's own self-use logic. Release remote + -- control; if the write cannot go through, the vendor timeout + -- reverts the inverter on its own within RC_TIMEOUT_S. + release_remote_control() end function driver_cleanup() + release_remote_control() end diff --git a/drivers/tests/lua_harness/host_mock.lua b/drivers/tests/lua_harness/host_mock.lua index b1fc7b1..8c08564 100644 --- a/drivers/tests/lua_harness/host_mock.lua +++ b/drivers/tests/lua_harness/host_mock.lua @@ -132,6 +132,11 @@ function host.set_model(model) host._model = model end +function host.set_rated_w(watts) + record_call("set_rated_w", watts) + host._rated_w = watts +end + function host.set_sn(serial_number) record_call("set_sn", serial_number) host._sn = serial_number diff --git a/drivers/tests/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 e0a926d..33a36b9 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.7.0" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] - control: false - size_bytes: 8181 - sha256: "102da78fe189a62bec0224a5a22fc27279abc15bc6ed69688bbcd50865fe49f2" + control: true + 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 39749a0..56e11ad 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,11 +1,11 @@ name: "foxess_h3_smart" -version: "0.1.0" +version: "0.7.0" tier: community author: "Sourceful Labs AB" protocol: modbus connectivity: local ders: [pv, battery, meter] -control: false +control: true tested_devices: - manufacturer: "Fox ESS" model_family: "1K5 (Three-Phase Hybrid)" @@ -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.7.0" - manufacturer: "Fox ESS" model_family: "H3-Smart" variants: [] regions: [] firmware_versions: "" notes: "Shares the 1K5 register map; not yet tested on H3-Smart hardware." - min_driver_version: "0.1.0" + min_driver_version: "0.7.0" upstream_docs: - url: "https://github.com/nathanmarlor/foxess_modbus" title: "nathanmarlor/foxess_modbus community register map (Inv.H3_SMART profile)" kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 8181 +size_bytes: 24678 dkb_id: "" -sha256: "102da78fe189a62bec0224a5a22fc27279abc15bc6ed69688bbcd50865fe49f2" +sha256: "fb8874a1c617e1257a2afdb93af4359b850b6689b608530cab5b0040c2b23267" 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..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.1.0", + "version": "0.7.0", "channel": "beta", "display_name": "FoxESS H3-Smart / 1K5", "identity": { @@ -45,10 +45,13 @@ "battery", "meter" ], - "control": [] + "control": [ + "battery" + ] }, "permissions": [ - "modbus.read" + "modbus.read", + "modbus.write" ], "telemetry": { "schema": "sourceful.telemetry/v2", @@ -71,15 +74,33 @@ } ] }, - "commands": [], - "read_only": true, + "commands": [ + { + "id": "battery", + "capability": "battery", + "runtime_action": "battery", + "inputs": [ + { + "name": "power_w", + "type": "number", + "unit": "W", + "required": true + } + ], + "description": "Battery power setpoint, translated to the vendor AC active-power register (AC = PV - target)." + } + ], + "read_only": false, "default_mode": { - "strategy": "not_applicable", - "description": "Read-only driver." + "strategy": "vendor_autonomous", + "description": "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..e9afeed 100644 --- a/support-status.json +++ b/support-status.json @@ -674,13 +674,13 @@ }, { "catalog_source": true, - "catalog_version": "0.1.0", + "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.1.0", - "control_enabled": false, + "candidate_package_version": "0.7.0", + "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", @@ -689,8 +689,8 @@ "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.1.0", - "control_enabled": false, + "candidate_package_version": "0.7.0", + "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed",