From 9fc2a4fb7899dede8679b15e1154664c70fe9227 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 8 Sep 2026 16:08:08 +0200 Subject: [PATCH] feat(saj): replace the untested stub with H2-Protocol battery telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous driver read an untested input-0x10xx map, fabricated zeros when registers were missing, and always emitted a battery — including on AS2 string inverters with no pack. Switch to the H2 holding map used by evcc saj-h2 and the community Home Assistant integration: identity, live PV/battery/grid power, per-phase meter, and BMS-gated battery telemetry. Control stays off until a named H2/HS2 proves held zero. Signed-off-by: Fredrik Ahlgren --- CHANGELOG.md | 2 + SUPPORT_STATUS.md | 4 +- devices.yaml | 12 +- drivers/INTEGRATION_OVERVIEW.md | 2 +- drivers/lua/saj.lua | 473 +++++++++++++----- .../tests/lua_harness/test_all_drivers.lua | 10 +- drivers/tests/test_saj.py | 298 +++++++++++ index.yaml | 7 +- manifests/saj.yaml | 26 +- support-status.json | 2 +- 10 files changed, 690 insertions(+), 146 deletions(-) create mode 100644 drivers/tests/test_saj.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 249bfd8..a5e3083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ### Changed +- **`saj` 1.2.0** — replace the untested input-0x10xx stub with the SAJ H2-Protocol holding map used by evcc `saj-h2` and the community Home Assistant integration. Identity from `0x8F00`, live PV/battery/grid power from `0x40A5`/`0x40A6`/`0x40AD`, per-phase meter from `0x4031`, and battery SoC/voltage/current only when the `0xA000` BMS block reports a pack (`BatNum` and `BatOnline` both non-zero). A missed live-power read emits nothing rather than a fabricated zero-watt site; an AS2 string inverter or a PV-only H2 no longer appears as an empty battery the planner can dispatch into. Battery power is negated at the boundary (vendor discharge-positive). Control stays off until a named H2/HS2 proves held zero — the protocol's AppMode/passive registers are documented in the driver and not written. + - **`easee_cloud` 1.3.0** — verify an active energy session before core can retain a confirmed battery level across restart. Keep that identity through pauses in the same driver process, but clear it on completion until a new active session proves its identity. An offline car may need its battery level confirmed after completion or restart; do not reuse another car's level. Match equivalent UTC timestamp formats from both session APIs. Failed or empty observations emit no telemetry. Invalid session payloads omit the identity while keeping fresh charger readings. - **easee_cloud** 1.2.0 — emit `request_active`: false only when the vehicle diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 0d232d9..0a8f30a 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -118,8 +118,8 @@ Catalog source is not proof that a target can install or run a driver. | pixii | 2.1.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | pixii_pv | 0.3.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | pixii_pv | 0.3.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| saj | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| saj | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | +| saj | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | +| saj | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | schneider_meter | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | | schneider_meter | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | | schrack_ev | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | diff --git a/devices.yaml b/devices.yaml index 8a58a7e..804b0b9 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1056,33 +1056,33 @@ manufacturers: protocols: - protocol: modbus driver: "saj" - version: "1.1.1" + version: "1.2.0" ders: [pv, battery, meter] control: false firmware_versions: "" - notes: "Community driver, untested" + notes: "String sibling: the driver must not emit a battery. The BMS block is probed and then left alone if it reports no pack or never answers." - name: "H2 (Three-Phase Hybrid)" variants: [H2-10K, H2-12K, H2-15K, H2-20K, H2-25K, H2-5K, H2-6K, H2-8K] regions: [] protocols: - protocol: modbus driver: "saj" - version: "1.1.1" + version: "1.2.0" ders: [pv, battery, meter] control: false firmware_versions: "" - notes: "Community driver, untested" + notes: "H2-Protocol holding map (evcc saj-h2 / community HA integration). Telemetry only; not yet verified on a named FTW site. Control stays off until a held-zero command is proven." - name: "HS2 (Single-Phase Hybrid)" variants: [HS2-3K, HS2-4K, HS2-5K, HS2-6K] regions: [] protocols: - protocol: modbus driver: "saj" - version: "1.1.1" + version: "1.2.0" ders: [pv, battery, meter] control: false firmware_versions: "" - notes: "Community driver, untested" + notes: "Same H2-Protocol map as the three-phase hybrid. Ampere.StoragePro / EKD-Solar is a rebadged HS2. Telemetry only." - name: "SMA" model_families: - name: "SMA Home Manager 2.0" diff --git a/drivers/INTEGRATION_OVERVIEW.md b/drivers/INTEGRATION_OVERVIEW.md index c79ca38..685bac8 100644 --- a/drivers/INTEGRATION_OVERVIEW.md +++ b/drivers/INTEGRATION_OVERVIEW.md @@ -44,7 +44,7 @@ NATS → Cloud ← standardized data model | **Kostal** | Plenticore Plus/BI, PIKO MP Plus/IQ | Modbus | Stub | | **Fox ESS** | H1, H3, H3 PRO, AIO-H3, KH | Modbus | Stub | | **Sofar Solar** | HYD, ME, HYD-ES | Modbus | Stub | -| **SAJ** | H2, HS2, AS2 | Modbus | Stub | +| **SAJ** | H2, HS2, AS2 | Modbus | Telemetry | | **KSTAR** | KSE, BluE-S | Modbus | Stub | | **AlphaESS** | Smile, G2 | Modbus | Stub | | **Victron Energy** | MultiPlus-II, Quattro-II, SmartSolar, SmartShunt | Modbus + MQTT | Stub | diff --git a/drivers/lua/saj.lua b/drivers/lua/saj.lua index 421caa0..3730c08 100644 --- a/drivers/lua/saj.lua +++ b/drivers/lua/saj.lua @@ -1,25 +1,187 @@ --- SAJ H2/HS2/AS2 Series Inverter Driver --- Emits: PV, Battery, Meter --- Register type: INPUT (FC 0x04) --- Port: 502 --- Community tier (untested) --- Hex addresses converted to decimal +-- SAJ H2 / HS2 hybrid (and AS2 string) inverter driver. +-- Emits: PV, Battery (when a pack is present), Meter +-- Protocol: Modbus TCP, HOLDING registers (FC 0x03), port 502, unit 1 +-- +-- Register map: SAJ H2-Protocol as implemented by the community sources +-- this driver was decoded from, not the untested input-0x10xx map the +-- previous stub used: +-- * stanus74/home-assistant-saj-h2-modbus (modbus_readers.py, register_overview.md) +-- * evcc template saj-h2 (templates/definition/meter/saj-h2.yaml) +-- Ampere.StoragePro / EKD-Solar is a rebadged HS2 and speaks the same map. +-- AS2 is the string (no-storage) sibling: same inverter registers, no pack. +-- +-- A document behind SAJ's own portal cannot be watched; the public maps +-- above are what a weekly fetch can still check. +-- +-- Sign convention (site: positive watts flow INTO the site): +-- pv.W always negative (generation) +-- battery.W positive = charging, negative = discharging +-- meter.W positive = import, negative = export +-- +-- Vendor signs, recorded because they are the whole reason this file exists: +-- 0x40A5 TotalPVPower I16 W, generation as a magnitude (evcc uses it +-- as PV production with no sign flip) +-- 0x40A6 TotalBatteryPower I16 W, discharge-positive (evcc battery is +-- discharge-positive and does not flip this +-- register, so we negate at the boundary) +-- 0x40AD SysGridPowerWall I16 W, import-positive (evcc grid, no flip) +-- 0xA00C Bat1SOC U16, 0.01 % (raw 8500 = 85.00 %) +-- 0xA00B BatOnline U16, 0 = no pack +-- 0xA000 BatNum U16, 0 = no pack +-- +-- A hybrid is sold with and without storage. Emitting battery.W = 0 / +-- battery.SoC_nom_fract = 0 when the BMS block is missing or reports no +-- pack does not mean "no battery": it means an empty pack the planner can +-- dispatch into. AS2 and a PV-only H2 commissioning must stay silent on +-- the battery stream. +-- +-- CONTROL is off. The H2 protocol has a real lever — AppMode 0x3647 +-- (0=self-use, 1=time-of-use, 2=backup, 3=passive) and the passive +-- charge/discharge setpoints in the 0x3636 block, which is how evcc and +-- the HA integration force charge. A 0 W hold must not fall back to +-- self-use: that lets the inverter charge from PV on its own, which is +-- why Huawei's unverified stop command was stripped. Until a named H2/HS2 +-- and firmware prove held zero, charge, discharge and a safe release, +-- this driver does not write. + +DRIVER = { + id = "saj", + name = "SAJ H2/HS2 hybrid inverter", + manufacturer = "SAJ", + version = "1.2.0", + host_api_min = 1, + host_api_max = 1, + protocols = { "modbus" }, + capabilities = { "pv", "battery", "meter" }, + read_only = true, + description = "SAJ H2/HS2 three- and single-phase hybrids (and AS2 string) via Modbus TCP. Battery telemetry only when the BMS block reports a pack. Read-only until a held-zero command is verified on hardware.", + homepage = "https://www.saj-electric.com", + authors = { "Sourceful Labs AB" }, + tested_models = { "H2", "HS2", "AS2" }, + verification_status = "experimental", + verification_notes = "Decoded from the SAJ H2-Protocol as implemented by evcc saj-h2 and stanus74/home-assistant-saj-h2-modbus. Not yet verified against live hardware on an FTW site. Control stays disabled until a named H2/HS2 proves held zero.", + connection_defaults = { + port = 502, + unit_id = 1, + }, +} PROTOCOL = "modbus" --- Reading a register the device does not have costs a failed read on every --- poll forever, and the host counts those against the poll whether or not Lua --- caught the error. Enough of them and the site is marked offline and reports --- nothing at all, which is worse than reporting one field less. So stop asking --- once a register has proved it is not there. Three tries, because one failure --- proves nothing -- the link may just have been slow. +---------------------------------------------------------------------------- +-- Register map (holding, documented hex next to the address we actually use) +---------------------------------------------------------------------------- + +-- Identity at 0x8F00: devtype, subtype, commver, SN (10), PC (10), versions. +local REG_INVERTER_INFO = 36608 -- 0x8F00, 29 registers +local INFO_COUNT = 29 +local SN_OFFSET = 4 -- 1-based index of the first SN register +local SN_REGS = 10 + +-- Live power at 0x40A5..0x40AD (additional_data_1_part_2, evcc's live keys). +local REG_POWER = 16549 -- 0x40A5 TotalPVPower +local POWER_COUNT = 9 -- through 0x40AD SysGridPowerWall +local IDX_PV_W = 1 +local IDX_BAT_W = 2 +local IDX_GRID_W = 9 + +-- PV strings and battery temperature at 0x406E (additional_data_1_part_1). +local REG_STRINGS = 16494 -- 0x406E +local STRINGS_COUNT = 15 + +-- Per-phase grid at 0x4031 (additional_data_4): 7 registers per phase × 3. +local REG_PHASES = 16433 -- 0x4031 RGridVolt +local PHASES_COUNT = 21 +local PHASE_STRIDE = 7 + +-- BMS / pack at 0xA000. Presence lives here, not in the live power word. +local REG_BMS = 40960 -- 0xA000 BatNum +local BMS_COUNT = 18 +local IDX_BAT_NUM = 1 -- 0xA000 +local IDX_BAT_CAP = 2 -- 0xA001 +local IDX_BAT_ONLINE = 12 -- 0xA00B +local IDX_BAT1_SOC = 13 -- 0xA00C +local IDX_BAT1_SOH = 14 -- 0xA00D +local IDX_BAT1_V = 15 -- 0xA00E +local IDX_BAT1_A = 16 -- 0xA00F +local IDX_BAT1_TEMP = 17 -- 0xA010 +local IDX_BAT1_CYCLES = 18 -- 0xA011 + +-- Lifetime energy, U32 BE × 0.01 kWh. +local REG_PV_ENERGY = 16581 -- 0x40C5 Total_PVEnergy +local REG_BAT_CHARGE_WH = 16589 -- 0x40CD bat_total_charge +local REG_BAT_DISCHARGE_WH = 16597 -- 0x40D5 bat_total_discharge + +---------------------------------------------------------------------------- +-- Helpers +---------------------------------------------------------------------------- + +local function finite(value) + if value ~= value then return nil end + if value == math.huge or value == -math.huge then return nil end + return value +end + +-- 0 and 0xFFFF are the usual "not present" sentinels on this map. A +-- sentinel is as inconclusive as no answer. +local function present_u16(value) + return value ~= nil and value ~= 0xFFFF +end + +local function decode_ascii(regs, start, count) + local s = "" + local last = start + count - 1 + for i = start, last do + local word = regs[i] + if word == nil then break end + local hi = math.floor(word / 256) + local lo = word % 256 + if hi > 32 and hi < 127 then s = s .. string.char(hi) end + if lo > 32 and lo < 127 then s = s .. string.char(lo) end + end + return s +end + +-- 0.01 kWh → Wh. Work on the 16-bit halves; combining them first overflows +-- on a 32-bit Lua integer build. +local function u32_to_wh(hi, lo) + local raw = host.decode_u32_be(hi, lo) + return finite(raw * 10) +end + +---------------------------------------------------------------------------- +-- Reading registers that may not exist +-- +-- The host counts every failed host.modbus_read against the poll whether or +-- not this driver caught the error. A register retried on every poll costs a +-- failed poll on every poll, and the stale-telemetry watchdog takes the +-- driver offline. The site then reports nothing at all, which is worse than +-- reporting one field less. +-- +-- Three attempts absorb a transient blip; after that we stop asking. A +-- restart re-probes, so firmware that gains the register is picked up. +-- +-- The live power block (0x40A5) deliberately does NOT go through here: when +-- it fails the driver emits nothing at all, so it never claims the device is +-- fine while paying for a failed read, and a blip cannot silence PV and the +-- meter for the rest of the session. +---------------------------------------------------------------------------- + local GIVE_UP_AFTER = 3 local read_failures = {} local function probe_read(addr, count, kind) if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end local ok, regs = pcall(host.modbus_read, addr, count, kind) - if ok and regs and regs[1] ~= nil then + if ok and regs ~= nil then + for i = 1, count do + if regs[i] == nil then + ok = false + break + end + end + end + if ok and regs ~= nil then read_failures[addr] = nil return regs end @@ -33,146 +195,213 @@ local function probe_read(addr, count, kind) return nil end +local function required_read(addr, count, kind) + local ok, regs = pcall(host.modbus_read, addr, count, kind) + if not ok or regs == nil then return nil end + for i = 1, count do + if regs[i] == nil then return nil end + end + return regs +end + +---------------------------------------------------------------------------- +-- Identity, once, bounded +---------------------------------------------------------------------------- + +local sn_read = false +local SN_ATTEMPTS = 3 +local sn_tries = 0 + +local function read_identity() + if sn_read or sn_tries >= SN_ATTEMPTS then return end + sn_tries = sn_tries + 1 + local regs = probe_read(REG_INVERTER_INFO, INFO_COUNT, "holding") + if not regs then return end + local sn = decode_ascii(regs, SN_OFFSET, SN_REGS) + if string.len(sn) > 0 then + host.set_sn(sn) + sn_read = true + end + -- devtype/subtype are numeric family codes, not a model string the + -- operator would recognise. Surface them as metrics; do not invent a + -- model name from a code we have not been taught. + if present_u16(regs[1]) then + host.emit_metric("saj_devtype", regs[1]) + end + if present_u16(regs[2]) then + host.emit_metric("saj_subtype", regs[2]) + end +end + +---------------------------------------------------------------------------- +-- Entry points +---------------------------------------------------------------------------- + function driver_init(config) host.set_make("SAJ") end function driver_poll() - -- ---- PV ---- - - -- PV1 voltage: 0x1058=4184, U16 × 0.1V - -- PV1 current: 0x1059=4185, U16 × 0.01A - local pv1_regs = probe_read(4184, 2, "input") - local mppt1_v, mppt1_a = 0, 0 - if pv1_regs then - mppt1_v = pv1_regs[1] * 0.1 - mppt1_a = pv1_regs[2] * 0.01 - end + read_identity() - -- PV2 voltage: 0x105C=4188, U16 × 0.1V - -- PV2 current: 0x105D=4189, U16 × 0.01A - local pv2_regs = probe_read(4188, 2, "input") - local mppt2_v, mppt2_a = 0, 0 - if pv2_regs then - mppt2_v = pv2_regs[1] * 0.1 - mppt2_a = pv2_regs[2] * 0.01 - end + --------------------------------------------------------------------- + -- Live power. One 9-register read covers PV, battery power and the + -- site-meter word evcc uses as the grid reading. + --------------------------------------------------------------------- + local power = required_read(REG_POWER, POWER_COUNT, "holding") - -- PV power: 0x1062-0x1063=4194-4195, U32 BE, W - local pvw_regs = probe_read(4194, 2, "input") - local pv_w = 0 - if pvw_regs then - pv_w = host.decode_u32_be(pvw_regs[1], pvw_regs[2]) - end + --------------------------------------------------------------------- + -- PV + --------------------------------------------------------------------- + if power then + local pv_mag = math.abs(host.decode_i16(power[IDX_PV_W])) + local pv = {} + pv.W = -pv_mag - -- Grid frequency: 0x104F=4175, U16 × 0.01Hz - local hz_regs = probe_read(4175, 1, "input") - local hz = 0 - if hz_regs then - hz = hz_regs[1] * 0.01 - end + local strings = probe_read(REG_STRINGS, STRINGS_COUNT, "holding") + if strings then + local function add_mppt(slot, v_i, a_i, w_i) + local v = finite(strings[v_i] * 0.1) + local a = finite(strings[a_i] * 0.01) + local w = strings[w_i] + -- A tracker that is not fitted answers zero volts. Leave it + -- out rather than reporting a dead string as a live one. + if v ~= nil and v > 0 then + pv["mppt" .. slot .. "_v"] = v + if a ~= nil then pv["mppt" .. slot .. "_a"] = a end + if w ~= nil then pv["mppt" .. slot .. "_w"] = w end + end + end + add_mppt(1, 4, 5, 6) + add_mppt(2, 7, 8, 9) + add_mppt(3, 10, 11, 12) + add_mppt(4, 13, 14, 15) + local bat_temp = finite(host.decode_i16(strings[1]) * 0.1) + if bat_temp ~= nil then + host.emit_metric("saj_bat_temp_c", bat_temp) + end + end + + local pv_e = probe_read(REG_PV_ENERGY, 2, "holding") + if pv_e then + local wh = u32_to_wh(pv_e[1], pv_e[2]) + if wh ~= nil then pv.lifetime_wh = wh end + end - -- Emit PV telemetry (W always negative for generation) - host.emit("pv", { - W = -pv_w, - mppt1_v = mppt1_v, - mppt1_a = mppt1_a, - mppt2_v = mppt2_v, - mppt2_a = mppt2_a, - }) - - -- ---- Battery ---- - - -- Battery power: 0x1096=4246, I16, W (positive=charge, negative=discharge) - local bw_regs = probe_read(4246, 1, "input") - local bat_w = 0 - if bw_regs then - bat_w = host.decode_i16(bw_regs[1]) + host.emit("pv", pv) end - -- Battery SoC: 0x1098=4248, U16, % - local bsoc_regs = probe_read(4248, 1, "input") - local bat_soc = 0 - if bsoc_regs then - bat_soc = bsoc_regs[1] / 100 -- percent to fraction + --------------------------------------------------------------------- + -- Meter + --------------------------------------------------------------------- + if power then + -- Vendor import-positive, matches the site convention. + local meter = { W = host.decode_i16(power[IDX_GRID_W]) } + local phases = probe_read(REG_PHASES, PHASES_COUNT, "holding") + if phases then + local function phase(name, base) + local v = finite(phases[base] * 0.1) + local a = finite(host.decode_i16(phases[base + 1]) * 0.01) + local hz = finite(phases[base + 2] * 0.01) + local w = host.decode_i16(phases[base + 4]) + if v ~= nil then meter[name .. "_V"] = v end + if a ~= nil then meter[name .. "_A"] = a end + if w ~= nil then meter[name .. "_W"] = w end + return hz + end + local hz1 = phase("L1", 1) + phase("L2", 1 + PHASE_STRIDE) + phase("L3", 1 + PHASE_STRIDE * 2) + if hz1 ~= nil then meter.Hz = hz1 end + end + host.emit("meter", meter) end - -- Emit Battery telemetry - host.emit("battery", { - W = bat_w, - SoC_nom_fract = bat_soc, - }) + --------------------------------------------------------------------- + -- Battery, only when the BMS block says a pack is there. + -- + -- Live battery power (0x40A6) answers on a string inverter too, usually + -- as zero. That is not presence. BatNum / BatOnline are. + --------------------------------------------------------------------- + local bms = probe_read(REG_BMS, BMS_COUNT, "holding") + local bat_num = bms and bms[IDX_BAT_NUM] or nil + local bat_online = bms and bms[IDX_BAT_ONLINE] or nil + local pack_present = bms + and present_u16(bat_num) + and present_u16(bat_online) + and bat_num > 0 + and bat_online > 0 - -- ---- Meter ---- + if pack_present and power then + local battery = {} + -- Vendor discharge-positive → site charge-positive. + battery.W = -host.decode_i16(power[IDX_BAT_W]) - -- Grid power: 0x1072-0x1073=4210-4211, I32 BE, W (positive=import) - local mw_regs = probe_read(4210, 2, "input") - local meter_w = 0 - if mw_regs then - meter_w = host.decode_i32_be(mw_regs[1], mw_regs[2]) - end + local soc_raw = bms[IDX_BAT1_SOC] + if present_u16(soc_raw) then + -- 0.01 % → 0..1 fraction. / 10000, not / 100: raw 8500 is 85 %. + battery.SoC_nom_fract = soc_raw / 10000 + end - -- Phase voltages: 0x1048=4168 (L1), 0x104A=4170 (L2), 0x104C=4172 (L3), U16 × 0.1V - local lv1_regs = probe_read(4168, 1, "input") - local l1_v = 0 - if lv1_regs then - l1_v = lv1_regs[1] * 0.1 - end + local v = bms[IDX_BAT1_V] + if present_u16(v) then + local volts = finite(v * 0.1) + if volts ~= nil then battery.V = volts end + end - local lv2_regs = probe_read(4170, 1, "input") - local l2_v = 0 - if lv2_regs then - l2_v = lv2_regs[1] * 0.1 - end + -- Current follows the same vendor sign as power (discharge-positive), + -- so it is negated to match battery.W. + local a = finite(host.decode_i16(bms[IDX_BAT1_A]) * 0.01) + if a ~= nil then battery.A = -a end - local lv3_regs = probe_read(4172, 1, "input") - local l3_v = 0 - if lv3_regs then - l3_v = lv3_regs[1] * 0.1 - end + local t = finite(host.decode_i16(bms[IDX_BAT1_TEMP]) * 0.1) + if t ~= nil then battery.temperature_C = t end - -- Phase currents: 0x1049=4169 (L1), 0x104B=4171 (L2), 0x104D=4173 (L3), U16 × 0.01A - local la1_regs = probe_read(4169, 1, "input") - local l1_a = 0 - if la1_regs then - l1_a = la1_regs[1] * 0.01 - end - - local la2_regs = probe_read(4171, 1, "input") - local l2_a = 0 - if la2_regs then - l2_a = la2_regs[1] * 0.01 - end + local chg = probe_read(REG_BAT_CHARGE_WH, 2, "holding") + if chg then + local wh = u32_to_wh(chg[1], chg[2]) + if wh ~= nil then battery.total_charge_Wh = wh end + end + local dis = probe_read(REG_BAT_DISCHARGE_WH, 2, "holding") + if dis then + local wh = u32_to_wh(dis[1], dis[2]) + if wh ~= nil then battery.total_discharge_Wh = wh end + end - local la3_regs = probe_read(4173, 1, "input") - local l3_a = 0 - if la3_regs then - l3_a = la3_regs[1] * 0.01 + host.emit("battery", battery) + host.emit_metric("saj_battery_count", bat_num) + host.emit_metric("saj_battery_online", bat_online) + if present_u16(bms[IDX_BAT_CAP]) then + host.emit_metric("saj_battery_capacity", bms[IDX_BAT_CAP]) + end + if present_u16(bms[IDX_BAT1_SOH]) then + host.emit_metric("saj_bat1_soh_pct", bms[IDX_BAT1_SOH] * 0.01) + end + if present_u16(bms[IDX_BAT1_CYCLES]) then + host.emit_metric("saj_bat1_cycles", bms[IDX_BAT1_CYCLES]) + end + elseif bms and not pack_present then + host.emit_metric("saj_battery_count", 0) + host.emit_metric("saj_battery_online", 0) end - -- Emit Meter telemetry - host.emit("meter", { - W = meter_w, - L1_V = l1_v, - L2_V = l2_v, - L3_V = l3_v, - L1_A = l1_a, - L2_A = l2_a, - L3_A = l3_a, - Hz = hz, - }) - return 5000 end function driver_command(action, power_w, cmd) - host.log("SAJ control not yet implemented: " .. action) + if action == "init" or action == "deinit" then + return true + end + host.log("warn", "SAJ: control is not enabled (action=" .. tostring(action) .. ")") return false end function driver_default_mode() + -- Read-only: the driver never took control, so there is nothing to release. end function driver_cleanup() - -- nothing to clean up + sn_read = false + sn_tries = 0 + read_failures = {} end diff --git a/drivers/tests/lua_harness/test_all_drivers.lua b/drivers/tests/lua_harness/test_all_drivers.lua index d90cb66..3fe1edb 100644 --- a/drivers/tests/lua_harness/test_all_drivers.lua +++ b/drivers/tests/lua_harness/test_all_drivers.lua @@ -276,6 +276,14 @@ local function setup_modbus_data(driver_name) holding[4] = 0; holding[5] = 3500 -- power U32 BE holding[7] = 0; holding[8] = 5000 -- session U32 BE holding[9] = 32 -- max current + elseif driver_name == "saj" then + -- H2 BMS block: a pack is present, SoC is 0.01 % (5000 → 50 %). + holding[40960] = 1 -- BatNum + holding[40971] = 1 -- BatOnline + holding[40972] = 5000 -- Bat1SOC + input[40960] = 1 + input[40971] = 1 + input[40972] = 5000 end --------------------------------------------------------------------------- @@ -298,7 +306,7 @@ local function setup_modbus_data(driver_name) 11038, -- FoxESS 54, -- KSTAR 36, -- AlphaESS - 4248, -- SAJ + 4248, -- unused by current SAJ map; harmless 1014, -- Growatt } for _, a in ipairs(soc_div100) do diff --git a/drivers/tests/test_saj.py b/drivers/tests/test_saj.py new file mode 100644 index 0000000..d9d37b7 --- /dev/null +++ b/drivers/tests/test_saj.py @@ -0,0 +1,298 @@ +"""SAJ H2/HS2 driver: H2-Protocol holding map, no fabricated zeros. + +The previous stub read untested input registers around 0x10xx, defaulted +every field to zero, and emitted a battery on AS2 string inverters that +have no pack. This suite holds the rewrite to the H2 holding map used by +evcc saj-h2 and stanus74/home-assistant-saj-h2-modbus, and to the rule +that a missing register is silence, not a zero. +""" + +from __future__ import annotations + +import math +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +HARNESS = ROOT / "drivers" / "tests" / "lua_harness" +DRIVER = ROOT / "drivers" / "lua" / "saj.lua" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + + +def _ascii_regs(text: str, width: int) -> list[int]: + padded = text.encode("ascii").ljust(width * 2, b"\x00") + return [padded[i] << 8 | padded[i + 1] for i in range(0, width * 2, 2)] + + +def _i16(value: int) -> int: + return value & 0xFFFF + + +def _u32_regs(value: int) -> tuple[int, int]: + return (value >> 16) & 0xFFFF, value & 0xFFFF + + +def _lua_list(values) -> str: + return "{" + ", ".join(str(v) for v in values) + "}" + + +SERIAL = "H2TESTSN0001" + +# Vendor signs, raw register units. +PV_W_VENDOR = 3200 # generation magnitude +BAT_W_VENDOR = -1800 # discharge-positive: negative = charging +GRID_W_VENDOR = 400 # import-positive +SOC_RAW = 8500 # 0.01 % → 85.00 % +BAT_V_RAW = 512 # 0.1 V → 51.2 V +BAT_A_VENDOR = -3500 # 0.01 A, discharge-positive → charging 35 A +BAT_TEMP_RAW = 254 # 0.1 °C → 25.4 °C +PV_ENERGY_RAW = 123456 # 0.01 kWh +BAT_CHARGE_RAW = 50000 +BAT_DISCHARGE_RAW = 40000 + + +def fixture_registers(*, pack: bool = True) -> str: + """Load a three-phase H2 hybrid. `pack=False` is an AS2 / PV-only H2.""" + info = [0] * 29 + info[0] = 0x0200 # devtype, not a sentinel + info[1] = 0x0001 + sn = _ascii_regs(SERIAL, 10) + for i, word in enumerate(sn): + info[3 + i] = word + + power = [0] * 9 + power[0] = _i16(PV_W_VENDOR) + power[1] = _i16(BAT_W_VENDOR) + power[8] = _i16(GRID_W_VENDOR) + + strings = [0] * 15 + strings[0] = _i16(BAT_TEMP_RAW) + strings[3] = 2752 # pv1 V 275.2 + strings[4] = 1130 # pv1 A 11.30 + strings[5] = 1800 # pv1 W + strings[6] = 3043 # pv2 V 304.3 + strings[7] = 1140 # pv2 A 11.40 + strings[8] = 1400 # pv2 W + + # 7 registers per phase: V, A, Hz, DCI, W, VA, PF + phases = [0] * 21 + for phase in range(3): + base = phase * 7 + phases[base] = 2350 + phase # 235.0 / 235.1 / 235.2 V + phases[base + 1] = _i16(500) # 5.00 A + phases[base + 2] = 5001 # 50.01 Hz + phases[base + 4] = _i16(200 - phase * 50) + + bms = [0] * 18 + if pack: + bms[0] = 1 # BatNum + bms[1] = 100 # BatCapcity (Ah, unit not claimed as Wh) + bms[11] = 1 # BatOnline + bms[12] = SOC_RAW + bms[13] = 9800 # SOH 98.00 % + bms[14] = BAT_V_RAW + bms[15] = _i16(BAT_A_VENDOR) + bms[16] = _i16(BAT_TEMP_RAW) + bms[17] = 42 + # else: BatNum=0, BatOnline=0 — present, but no pack. + + pv_e_hi, pv_e_lo = _u32_regs(PV_ENERGY_RAW) + chg_hi, chg_lo = _u32_regs(BAT_CHARGE_RAW) + dis_hi, dis_lo = _u32_regs(BAT_DISCHARGE_RAW) + + return "\n".join([ + "host._modbus_registers.holding[36608] = " + _lua_list(info), + "host._modbus_registers.holding[16549] = " + _lua_list(power), + "host._modbus_registers.holding[16494] = " + _lua_list(strings), + "host._modbus_registers.holding[16433] = " + _lua_list(phases), + "host._modbus_registers.holding[40960] = " + _lua_list(bms), + "host._modbus_registers.holding[16581] = " + _lua_list([pv_e_hi, pv_e_lo]), + "host._modbus_registers.holding[16589] = " + _lua_list([chg_hi, chg_lo]), + "host._modbus_registers.holding[16597] = " + _lua_list([dis_hi, dis_lo]), + ]) + + +def run_lua(body: str) -> dict[str, str]: + script = f''' +package.path = "{HARNESS}/?.lua;" .. package.path +require("host_mock") +host.reset() +{body} +''' + result = subprocess.run([str(LUA), "-e", script], + capture_output=True, text=True, cwd=ROOT) + assert result.returncode == 0, result.stdout + result.stderr + return dict(line.split(" ", 1) + for line in result.stdout.strip().splitlines() if " " in line) + + +REPORT = ''' +local function last(t) return t and t[#t] or nil end +local pv = last(host._emitted["pv"]) +local bat = last(host._emitted["battery"]) +local met = last(host._emitted["meter"]) +print("PV_EMITS " .. tostring(host._emitted["pv"] and #host._emitted["pv"] or 0)) +print("BAT_EMITS " .. tostring(host._emitted["battery"] and #host._emitted["battery"] or 0)) +print("MET_EMITS " .. tostring(host._emitted["meter"] and #host._emitted["meter"] or 0)) +if pv then + print("PV_W " .. tostring(pv.W)) + print("PV_MPPT1_V " .. tostring(pv.mppt1_v)) + print("PV_MPPT2_W " .. tostring(pv.mppt2_w)) + print("PV_GEN_WH " .. tostring(pv.lifetime_wh)) +end +if bat then + print("BAT_W " .. tostring(bat.W)) + print("BAT_V " .. tostring(bat.V)) + print("BAT_A " .. tostring(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 " .. tostring(met.W)) + print("MET_HZ " .. tostring(met.Hz)) + print("MET_L1V " .. tostring(met.L1_V)) + print("MET_L1W " .. tostring(met.L1_W)) + print("MET_L2W " .. tostring(met.L2_W)) +end +print("MAKE " .. tostring(host._make)) +print("SN " .. tostring(host._sn)) +''' + + +def poll_once(extra: str = "", pack: bool = True) -> dict[str, str]: + return run_lua(f''' +{fixture_registers(pack=pack)} +{extra} +dofile("{DRIVER}") +driver_init({{}}) +local ok, err = pcall(driver_poll) +if not ok then print("POLL_ERROR " .. tostring(err)) os.exit(1) end +{REPORT} +''') + + +def test_hybrid_capture_in_site_convention(): + out = poll_once() + + assert out["PV_EMITS"] == "1" + assert float(out["PV_W"]) == -PV_W_VENDOR + assert math.isclose(float(out["PV_MPPT1_V"]), 275.2, rel_tol=1e-5) + assert float(out["PV_MPPT2_W"]) == 1400 + assert float(out["PV_GEN_WH"]) == PV_ENERGY_RAW * 10 + + # Vendor discharge-positive charging (-1800) flips to site +1800. + assert out["BAT_EMITS"] == "1" + assert float(out["BAT_W"]) == 1800 + assert math.isclose(float(out["BAT_V"]), 51.2, rel_tol=1e-5) + assert math.isclose(float(out["BAT_A"]), 35.0, rel_tol=1e-5) + assert math.isclose(float(out["BAT_SOC"]), 0.85, rel_tol=1e-5) + assert math.isclose(float(out["BAT_TEMP"]), 25.4, rel_tol=1e-5) + assert float(out["BAT_CHG_WH"]) == BAT_CHARGE_RAW * 10 + assert float(out["BAT_DIS_WH"]) == BAT_DISCHARGE_RAW * 10 + + assert out["MET_EMITS"] == "1" + assert float(out["MET_W"]) == GRID_W_VENDOR + assert math.isclose(float(out["MET_HZ"]), 50.01, rel_tol=1e-5) + assert math.isclose(float(out["MET_L1V"]), 235.0, rel_tol=1e-5) + assert float(out["MET_L1W"]) == 200 + assert float(out["MET_L2W"]) == 150 + + +def test_identity_from_inverter_info_block(): + out = poll_once() + assert out["MAKE"] == "SAJ" + assert out["SN"] == SERIAL + + +def test_no_battery_stream_when_pack_is_offline(): + """AS2 / PV-only H2: BMS answers, BatNum and BatOnline are zero.""" + out = poll_once(pack=False) + assert out["BAT_EMITS"] == "0" + assert out["PV_EMITS"] == "1" + assert out["MET_EMITS"] == "1" + + +def test_no_battery_stream_when_bms_block_is_missing(): + out = poll_once('host._modbus_read_fail_addresses[40960] = "Illegal Data Address"') + assert out["BAT_EMITS"] == "0" + assert out["PV_EMITS"] == "1" + assert out["MET_EMITS"] == "1" + + +def test_power_block_failure_emits_nothing(): + """A missed live-power read is silence, not a fabricated zero-watt site.""" + out = poll_once('host._modbus_read_fail_addresses[16549] = "timeout"') + assert out["PV_EMITS"] == "0" + assert out["MET_EMITS"] == "0" + assert out["BAT_EMITS"] == "0" + + +def test_soc_omitted_when_bat1_soc_is_sentinel(): + out = poll_once( + "host._modbus_registers.holding[40960][13] = 0xFFFF") + assert out["BAT_EMITS"] == "1" + assert out["BAT_SOC"] == "nil" + assert float(out["BAT_W"]) == 1800 + + +def test_command_refuses_battery_writes(): + out = run_lua(f''' +{fixture_registers()} +dofile("{DRIVER}") +driver_init({{}}) +local ok, err = pcall(driver_command, "battery", 1000, {{}}) +print("CMD_OK " .. tostring(ok)) +print("CMD_RET " .. tostring(err == nil and "nil" or tostring(err))) +print("WRITES " .. tostring(host._modbus_write_attempts)) +''') + # pcall ok means the function ran; the return value is the first result. + # driver_command returns false, so ok is true and we need the actual return. + # pcall returns (true, false) on a successful call that returned false. + assert out["WRITES"] == "0" + + +def test_command_return_is_false(): + out = run_lua(f''' +{fixture_registers()} +dofile("{DRIVER}") +driver_init({{}}) +local ret = driver_command("battery", -500, {{}}) +print("RET " .. tostring(ret)) +local init_ret = driver_command("init", 0, {{}}) +print("INIT " .. tostring(init_ret)) +''') + assert out["RET"] == "false" + assert out["INIT"] == "true" + + +def test_bms_absence_settles_and_keeps_pv(): + """After three BMS failures the driver stops asking and still reports PV.""" + out = run_lua(f''' +{fixture_registers()} +host._modbus_read_fail_addresses[40960] = "Illegal Data Address" +dofile("{DRIVER}") +driver_init({{}}) +for i = 1, 10 do + host._emitted = {{}} + pcall(driver_poll) +end +local reads = 0 +for _, call in ipairs(host._calls) do + if call.func == "modbus_read" and call.args[1] == 40960 then + reads = reads + 1 + end +end +print("BMS_READS " .. tostring(reads)) +print("PV_EMITS " .. tostring(host._emitted["pv"] and #host._emitted["pv"] or 0)) +print("BAT_EMITS " .. tostring(host._emitted["battery"] and #host._emitted["battery"] or 0)) +''') + assert out["BMS_READS"] == "3" + assert out["PV_EMITS"] == "1" + assert out["BAT_EMITS"] == "0" diff --git a/index.yaml b/index.yaml index 6ddc4d7..ae1ca39 100644 --- a/index.yaml +++ b/index.yaml @@ -526,14 +526,15 @@ drivers: size_bytes: 6692 sha256: "d0901b6478e05b5cdf46fc2a9675700ba6008be2feb370be0ff7e87d2f1de883" - name: "saj" - version: "1.1.1" + version: "1.2.0" tier: community protocol: modbus connectivity: local + setup: [device_ui] ders: [pv, battery, meter] control: false - size_bytes: 4855 - sha256: "3eec98c298872444cbddfc33c7c666decc367c427e5aacb7e1c240a3d6cdd21f" + size_bytes: 16074 + sha256: "0588a78a9664cf767557bc0cfb17e8cbd684709cb019d126ab00edadc64c47b6" - name: "schneider_meter" version: "1.0.5" tier: community diff --git a/manifests/saj.yaml b/manifests/saj.yaml index 0e7724a..152d026 100644 --- a/manifests/saj.yaml +++ b/manifests/saj.yaml @@ -1,9 +1,10 @@ name: "saj" -version: "1.1.1" +version: "1.2.0" tier: community author: "Sourceful Labs AB" protocol: modbus connectivity: local +setup: [device_ui] ders: [pv, battery, meter] control: false tested_devices: @@ -12,29 +13,34 @@ tested_devices: variants: [H2-5K, H2-6K, H2-8K, H2-10K, H2-12K, H2-15K, H2-20K, H2-25K] regions: [] firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" + notes: "H2-Protocol holding map (evcc saj-h2 / community HA integration). Telemetry only; not yet verified on a named FTW site. Control stays off until a held-zero command is proven." + min_driver_version: "1.2.0" - manufacturer: "SAJ" model_family: "HS2 (Single-Phase Hybrid)" variants: [HS2-3K, HS2-4K, HS2-5K, HS2-6K] regions: [] firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" + notes: "Same H2-Protocol map as the three-phase hybrid. Ampere.StoragePro / EKD-Solar is a rebadged HS2. Telemetry only." + min_driver_version: "1.2.0" - manufacturer: "SAJ" model_family: "AS2 (Three-Phase String)" variants: [AS2-3K, AS2-4K, AS2-5K, AS2-6K, AS2-8K, AS2-10K] regions: [] firmware_versions: "" - notes: "Community driver, untested" - min_driver_version: "1.0.0" + notes: "String sibling: the driver must not emit a battery. The BMS block is probed and then left alone if it reports no pack or never answers." + min_driver_version: "1.2.0" +upstream_docs: + - url: "https://raw.githubusercontent.com/stanus74/home-assistant-saj-h2-modbus/main/custom_components/saj_h2_modbus/modbus_readers.py" + title: "Community SAJ H2 Modbus register map (stanus74/home-assistant-saj-h2-modbus)" + kind: register_map + url_stability: volatile min_host_version: "2.0.0" -size_bytes: 4855 +size_bytes: 16074 dkb_id: "" -sha256: "3eec98c298872444cbddfc33c7c666decc367c427e5aacb7e1c240a3d6cdd21f" +sha256: "0588a78a9664cf767557bc0cfb17e8cbd684709cb019d126ab00edadc64c47b6" signature: "" bytecode_sha256: "" bytecode_signature: "" bytecode_size: 0 -changelog: "" +changelog: "Replace the untested input-0x10xx stub with the H2-Protocol holding map: identity, live power, per-phase meter, BMS-gated battery telemetry, no fabricated zeros. Control remains off." diff --git a/support-status.json b/support-status.json index d1b8c85..c84ed05 100644 --- a/support-status.json +++ b/support-status.json @@ -1598,7 +1598,7 @@ }, { "catalog_source": true, - "catalog_version": "1.1.1", + "catalog_version": "1.2.0", "driver_id": "saj", "package_id": null, "targets": {