Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
targeting both must use.

### Fixed
- **`foxess_h3_smart` 0.9.4 — grid charging at night finally works.** It never had: the BMS-ceiling guard read `Pwr_limit_Bat_up` milliseconds after writing the session enable, but the inverter sleeps in standby at night and its master samples the RC block slowly (the same fact behind the 60 s timeout floor), so the register still reported the sleeping value, the guard refused, the host reverted the driver, and the release put the inverter straight back to sleep — every retry slot, all night (observed 2026-09-08: five hours of refusals at 10% SoC with a 20 °C pack; the unguarded v0.2.0 had night-imported happily, and the reference implementation drives the session continuously rather than gate on a first read). A night charge that meets a floor-level limit is now **accepted as pending**: the driver holds AC = 0 — no import, no discharge, session keeping the inverter awake — re-reads the limit every poll, applies the true setpoint the moment the BMS wakes, and gives up only after a 3-minute patience window (a genuine cold/fault hold). Daylight behaviour unchanged: an awake inverter reporting a floor-level limit is a real refusal. The v2 entrypoint returns `accepted` / `charge_pending_bms_wake` for the pending state — the contract's word for "heard you, not done yet". New metric `foxess_bms_charge_limit_w` every poll, so the next BMS mood swing is a time series instead of a mystery
- **`foxess_h3_smart` 0.9.5 — the pending-charge wait is now universal, because the BMS limit follows activity, not capability.** 0.9.4 fixed the night refusal loop but kept the instant refusal in daylight — and the same loop promptly reappeared with the sun up: a battery idle at the SoC floor under weak PV dozes off and reports no charge headroom even in daylight (two incidents on the 1K5, 2026-08-19/20, SoC 11–14%, battery 0 W, limit below the floor, sun up; the host re-commanded every 5 minutes and the fault flapped indefinitely). A floor-level limit now always accepts the charge as pending — and the pending state PROBES with a gentle 200 W charge (night: AC = −200; daylight: AC = pv − 200) rather than holding at zero, because live metric data showed the limit register follows power FLOW, not capability: 12+ minutes of flat-0 limit across politely-held sessions, while the unguarded v0.2.0 had charged 4.5 kW from the same idle state just by asking. And because the same metric showed the register staying flat 0 even while the pack demonstrably charged at the probe level, capability is read from **measured uptake** when the register is dead: the request ramps 500 W per poll while the battery follows (decaying a step on stalls, never exceeding the commanded target), the register path takes over the moment `Pwr_limit_Bat_up` actually rises, and the patience window only counts time without uptake — a pack that is charging is success in progress, while a genuinely full or cold pack sees at most 3 minutes of a 200 W request it is free to ignore. Also corrects the incident dates the 0.9.4 notes carried (August, not September)

- **`foxess_h3_smart` 0.9.4 — grid charging at night finally works.** It never had: the BMS-ceiling guard read `Pwr_limit_Bat_up` milliseconds after writing the session enable, but the inverter sleeps in standby at night and its master samples the RC block slowly (the same fact behind the 60 s timeout floor), so the register still reported the sleeping value, the guard refused, the host reverted the driver, and the release put the inverter straight back to sleep — every retry slot, all night (observed 2026-08-18/19: five hours of refusals at 10% SoC with a 20 °C pack; the unguarded v0.2.0 had night-imported happily, and the reference implementation drives the session continuously rather than gate on a first read). A night charge that meets a floor-level limit is now **accepted as pending**: the driver holds AC = 0 — no import, no discharge, session keeping the inverter awake — re-reads the limit every poll, applies the true setpoint the moment the BMS wakes, and gives up only after a 3-minute patience window (a genuine cold/fault hold). Daylight behaviour unchanged: an awake inverter reporting a floor-level limit is a real refusal. The v2 entrypoint returns `accepted` / `charge_pending_bms_wake` for the pending state — the contract's word for "heard you, not done yet". New metric `foxess_bms_charge_limit_w` every poll, so the next BMS mood swing is a time series instead of a mystery

### Changed
- **`huawei` 2.1.2 and `ferroamp_modbus` 2.1.2 are now telemetry-only.** Both drivers exposed battery control without a hardware-verified way to hold 0 W: Huawei's stop command returned the inverter to self-consumption, and Ferroamp Modbus selected auto mode. FTW uses 0 W as an enforced battery hold, so either path could let native charging continue during an idle plan slot. The Lua boundary now refuses every command and performs no writes during command, default or cleanup. Huawei control can return after a SUN2000/LUNA2000 test proves held charge, discharge and zero plus a safe release. Ferroamp control remains available through the hardware-verified MQTT driver; its Modbus path needs the same proof before it can write again.
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ Catalog source is not proof that a target can install or run a driver.
| ferroamp_dc2_v2x | 2.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| ferroamp_modbus | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no |
| ferroamp_modbus | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no |
| foxess_h3_smart | 0.9.4 | ftw-core | not_assessed | 0.9.4 | — | not_recorded | — | not_assessed | yes |
| foxess_h3_smart | 0.9.4 | blixt-l1 | not_assessed | 0.9.4 | — | not_recorded | — | not_assessed | yes |
| foxess_h3_smart | 0.9.5 | ftw-core | not_assessed | 0.9.5 | — | not_recorded | — | not_assessed | yes |
| foxess_h3_smart | 0.9.5 | blixt-l1 | not_assessed | 0.9.5 | — | 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 |
Expand Down
4 changes: 2 additions & 2 deletions devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "foxess_h3_smart"
version: "0.9.4"
version: "0.9.5"
ders: [pv, battery, meter]
control: true
firmware_versions: ""
Expand All @@ -428,7 +428,7 @@ manufacturers:
protocols:
- protocol: modbus
driver: "foxess_h3_smart"
version: "0.9.4"
version: "0.9.5"
ders: [pv, battery, meter]
control: true
firmware_versions: ""
Expand Down
132 changes: 90 additions & 42 deletions drivers/lua/foxess_h3_smart.lua
Original file line number Diff line number Diff line change
Expand Up @@ -67,25 +67,26 @@
-- 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 response depends on whether the inverter can be believed:
-- daylight: the inverter is awake, the refusal is real (full,
-- cold, BMS hold) -- release and report why; native
-- self-use surplus-charges better than a fight.
-- night: the inverter sleeps in standby and its master
-- samples the RC block slowly, so the limit register
-- still shows the sleeping value right after session
-- enable. Refusing here re-releases the session and
-- the inverter never wakes -- the loop that made
-- grid charging never work at night (found
-- 2026-09-08: 5 h of refusals, 10% SoC, 20 C pack,
-- while the unguarded v0.2.0 had night-imported
-- happily, and the reference drives the session
-- continuously rather than gate on first read).
-- Instead: accept the command, hold AC = 0 (no
-- import, session keeps the inverter awake), re-read
-- the limit every poll, apply the true setpoint when
-- it wakes, give up only after a 3 min patience
-- window (a genuine cold/fault hold).
-- the charge is accepted as PENDING, day or night: command a
-- gentle CHARGE_PROBE_W charge (night: AC = -probe; daylight:
-- AC = pv - probe), keep the session alive, re-read the limit
-- every poll, apply the true setpoint the moment it rises, give
-- up after a 3 min patience window (a genuine full / cold /
-- fault hold; native self-use then serves better than a fight).
-- Why a probe instead of refusing or waiting: the limit register
-- follows power FLOW, not capability. A pack that has sat idle
-- dozes off and reports no headroom until current actually
-- moves. Refusing re-releases the session so nothing ever asks
-- (the loop behind grid charging never working at night,
-- 2026-08-18/19: 5 h of refusals at 10% SoC, 20 C pack), a
-- night-only fix left the identical loop in daylight with the
-- battery idle at the SoC floor (2026-08-19/20, SoC 11-14%,
-- sun up), and a polite held-at-zero session proved insufficient
-- on hardware (metric: 12+ min of flat-0 limit across held
-- sessions). The unguarded v0.2.0 charged 4.5 kW from this
-- exact idle state just by asking -- asking is the wake-up call.
-- The reference avoids the entire class by never gating on a
-- first read; the probe keeps its spirit and our ceiling guard.
-- 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):
Expand Down Expand Up @@ -147,7 +148,7 @@ DRIVER = {
id = "foxess_h3_smart",
name = "FoxESS H3-Smart / 1K5",
manufacturer = "Fox ESS",
version = "0.9.4",
version = "0.9.5",
host_api_min = 1,
host_api_max = 2,
protocols = { "modbus" },
Expand All @@ -172,7 +173,7 @@ PROTOCOL = "modbus"
-- other field here.
DRIVER_MANIFEST = {
name = "foxess_h3_smart",
version = "0.9.4",
version = "0.9.5",
role = "inverter",
requires = {},
options = {},
Expand Down Expand Up @@ -248,6 +249,24 @@ local CHARGE_BMS_FLOOR_W = 250
-- The RC block is sampled slowly by the inverter master (the same
-- fact behind the 60 s timeout floor), so seconds are not enough.
local CHARGE_WAKE_PATIENCE_MS = 180000
-- The wake stimulus, and the uptake ramp behind it. Hardware findings
-- on the discovering 1K5 (2026-08-20 morning, via the limit metric):
-- a session that asks for nothing never wakes the pack (12+ min of
-- flat-0 limit across held-at-zero sessions), and the register stays
-- flat 0 EVEN WHILE the pack demonstrably charges at the probe level
-- -- in the idle-at-floor state the register is simply dead, while
-- the pack itself takes whatever is asked (the unguarded v0.2.0
-- imported 4.5 kW from this exact state). So: probe with a gentle
-- charge, and when the pack provably follows, ramp the request on
-- MEASURED UPTAKE -- the guard the header's charge section always
-- named as the next step. The register path still takes over the
-- moment Pwr_limit_Bat_up actually rises.
local CHARGE_PROBE_W = 200
-- Ramp step per poll while escalating on uptake, and the fraction of
-- the current request the pack must be following for the ramp (or the
-- request itself) to be trusted.
local CHARGE_RAMP_STEP_W = 500
local CHARGE_FOLLOW_FRACTION = 0.5
local PV_VOLTS_DAYLIGHT = 70
-- The PV reading is DC-side; the AC terminals see less after
-- conversion, and feeding raw DC PV into the AC setpoint makes the
Expand Down Expand Up @@ -278,9 +297,15 @@ local fault_active = nil
local rc_enabled = false
local rc_target_w = nil -- site convention: positive = charge
local rc_command_ms = 0
-- millis when a charge first found the BMS limit below the floor at
-- night; nil when no charge is waiting. See CHARGE_WAKE_PATIENCE_MS.
-- millis when a charge first found the BMS limit below the floor;
-- nil when no charge is waiting. See CHARGE_WAKE_PATIENCE_MS.
local charge_wake_since = nil
-- The uptake ramp's current request (W) while the limit register is
-- dead; nil when not ramping. Grows by CHARGE_RAMP_STEP_W per poll
-- while the measured battery charge keeps following.
local charge_ramp_w = nil
-- Battery power from the last poll, site convention (+ = charging).
local last_bat_w = nil
-- PV curtail cap (AC-output ceiling, W). nil = none. Armed by the
-- `curtail` action; only honoured when the operator opted in via
-- supports_pv_curtail (the host injects _supports_pv_curtail).
Expand Down Expand Up @@ -411,24 +436,39 @@ local function compute_vendor(battery_target_w)
return nil, "battery charge limit unreadable"
end
if limit < CHARGE_BMS_FLOOR_W then
-- Daylight: the inverter is awake, so a floor-level limit is a
-- real refusal (full, cold, BMS hold) -- release and let native
-- self-use surplus-charge instead.
-- PENDING, day or night: the limit register follows the battery's
-- ACTIVITY, not its capability -- an idle battery dozes off and
-- reports no headroom until a session asks and waits. Refusing
-- releases the session so nothing ever asks: that loop made grid
-- charging never work at night (2026-08-18/19), and the night-only
-- 0.9.4 fix left the identical loop in daylight with the battery
-- idle at the SoC floor under weak PV (2026-08-19/20, SoC 11-14%,
-- battery 0 W, sun up). Hold the battery at zero instead -- night:
-- AC = 0; daylight: AC = pv, so PV passes to house + grid -- keep
-- the session alive, re-read every poll, and let the refresh
-- path's patience window bound a genuine full/cold/fault hold.
-- Uptake ramp: the pack following the current request is the
-- capability signal the dead register cannot give. Grow the
-- request one step per poll while it follows; fall back to the
-- probe when it stops.
local request = CHARGE_PROBE_W
local following = (last_bat_w or 0) >= (charge_ramp_w or CHARGE_PROBE_W) * CHARGE_FOLLOW_FRACTION
if following then
request = math.min(battery_target_w, (charge_ramp_w or CHARGE_PROBE_W) + CHARGE_RAMP_STEP_W)
else
-- Decay one step rather than reset: the inverter can lag a poll
-- behind a raised request, and a full reset would saw-tooth.
request = math.max(CHARGE_PROBE_W, (charge_ramp_w or CHARGE_PROBE_W) - CHARGE_RAMP_STEP_W)
end
charge_ramp_w = request
local why = "probing " .. request .. " W while the BMS charge limit sleeps"
if (last_pv_volts or 0) >= PV_VOLTS_DAYLIGHT then
return nil, "battery is not accepting charge now"
if last_pv_w == nil then
return nil, "no PV reading yet"
end
return last_pv_w * PV_AC_EFF - request, why, true
end
-- Night: the inverter has been in standby and its master samples
-- the RC block slowly, so Pwr_limit_Bat_up still reports the
-- sleeping value right after the session enable. Failing here
-- releases the session and puts the inverter straight back to
-- sleep -- the loop that made grid charging never work at night
-- (discovered 2026-09-08: five hours of refusals at 10% SoC with
-- a 20 C battery, while the unguarded build had night-imported
-- happily). Hold AC = 0 instead: no import, no discharge, session
-- alive; every poll re-reads the limit and the real setpoint
-- follows the moment the BMS wakes. The patience window in the
-- refresh path bounds how long a genuine refusal can hold this.
return 0, "waiting for the BMS charge limit to wake", true
return -request, why, true
end
local p_eff = math.min(battery_target_w, limit - CHARGE_BMS_MARGIN_W)
-- Guard 2: daylight by string voltage, not power.
Expand Down Expand Up @@ -517,6 +557,7 @@ local function release_remote_control()
rc_target_w = nil
prev_vendor_w = nil
charge_wake_since = nil
charge_ramp_w = nil
if rc_enabled then
rc_enabled = false
checked_write(RC_ENABLE_ADDR, 0)
Expand Down Expand Up @@ -604,6 +645,7 @@ function driver_poll()
if power then
local bat_out = {}
bat_out.W = -i32(power, POWER_ADDR, 39237)
last_bat_w = bat_out.W
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)
Expand Down Expand Up @@ -682,7 +724,7 @@ function driver_poll()
if prev_vendor_w ~= nil then
host.emit_metric("foxess_rc_setpoint_w", prev_vendor_w)
end
-- The BMS charge ceiling as a time series: the 2026-09-08 night of
-- The BMS charge ceiling as a time series: the 2026-08-18/19 night of
-- refused charging was undiagnosable precisely because this value
-- was only ever read inside command paths. One extra register pair
-- per poll buys the ability to see the BMS's mood remotely.
Expand All @@ -709,6 +751,11 @@ function driver_poll()
host.log("warn", "foxess_h3_smart: releasing remote control: " .. why)
release_all()
elseif pending then
if (last_bat_w or 0) >= CHARGE_PROBE_W * CHARGE_FOLLOW_FRACTION then
-- The pack is charging: pending-with-uptake is success in
-- progress, not a refusal to time out.
charge_wake_since = host.millis()
end
if charge_wake_since == nil then
charge_wake_since = host.millis()
elseif host.millis() - charge_wake_since > CHARGE_WAKE_PATIENCE_MS then
Expand All @@ -721,6 +768,7 @@ function driver_poll()
end
else
charge_wake_since = nil
charge_ramp_w = nil
end
end
elseif rc_enabled then
Expand Down Expand Up @@ -795,7 +843,7 @@ function driver_command(action, value, context)
if pending then
if charge_wake_since == nil then
charge_wake_since = host.millis()
host.log("info", "foxess_h3_smart: charge accepted; holding 0 W while the BMS charge limit wakes")
host.log("info", "foxess_h3_smart: charge accepted as pending; " .. tostring(why))
end
else
charge_wake_since = nil
Expand Down Expand Up @@ -883,7 +931,7 @@ function driver_command_v2(cmd)
end
return {
status = "accepted", code = "charge_pending_bms_wake",
message = "holding 0 W while the BMS charge limit wakes; charge follows on refresh",
message = "probing " .. CHARGE_PROBE_W .. " W while the BMS charge limit wakes; full charge follows on refresh",
device_state = "controlled",
evidence = { "write_ack", "readback" },
}
Expand Down
Loading