From a25973a6687458b855aff72eabbf43c6b150c6ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Nov 2026 11:30:24 +0000 Subject: [PATCH 1/2] feat(saj-emanager): add read-only eManager telemetry The community register sheet does not state sign, word order, or whether phase voltage and frequency are summed. Control stays off until a named eManager proves a held zero. Signed-off-by: Cursor Agent Co-authored-by: David Mozart --- CHANGELOG.md | 22 ++ SUPPORT_STATUS.md | 2 + devices.yaml | 11 + drivers/lua/saj-emanager.lua | 599 +++++++++++++++++++++++++++++ drivers/tests/test_saj-emanager.py | 541 ++++++++++++++++++++++++++ index.yaml | 9 + manifests/saj-emanager.yaml | 38 ++ support-status.json | 21 + 8 files changed, 1243 insertions(+) create mode 100644 drivers/lua/saj-emanager.lua create mode 100644 drivers/tests/test_saj-emanager.py create mode 100644 manifests/saj-emanager.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index b1affa6..290d17d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +### saj-emanager 0.1.0 + +Read-only telemetry for a SAJ eManager C1 Pro in front of one or more CHS2/CH2 +hybrids. Modbus TCP only, port 502. The register sheet marks RTU as not supported. + +- PV, grid and battery come from the aggregate block at `0x848A`. Battery + telemetry is emitted only when capacity, SoC or battery power says a pack + is there. A failed or short read is left out; it is not reported as 0 W. +- Phase voltage and frequency are divided by `DeviceNum` only when the scaled + value cannot be one phase and the quotient can. The register sheet does not + say those registers are summed. Eniris does, and only for voltage and + frequency. Power is never divided. +- Signs and 32-bit word order follow the H2 driver (high word first, battery + discharge-positive, grid import-positive). The eManager sheet does not state + either. Both are unverified. +- `0x8400`–`0x8405` are documented, including the ±11000 setpoint scaling and + the SEC-C map that reuses the same addresses, and are not written. +- Not run on hardware. The first site to check is Castellum Örebro. + +FTW's bundled copy does not move with this commit. Bump the pin and run +`scripts/sync-bundled-drivers.sh`. + ### zap 3.1.3 `read_pv` and `read_battery` read again on Zap firmware 7.x. Data model v2 calls diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 72c770c..8d46356 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -120,6 +120,8 @@ Catalog source is not proof that a target can install or run a driver. | pixii_pv | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| saj-emanager | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| saj-emanager | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | schneider_meter | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 7936061..d8939ae 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1098,6 +1098,17 @@ manufacturers: control: false firmware_versions: "" notes: "Same H2-Protocol map as the three-phase hybrid. Ampere.StoragePro / EKD-Solar is a rebadged HS2. Telemetry only." + - name: "eManager C1 Pro" + variants: [eManager C1 Pro] + regions: [] + protocols: + - protocol: modbus + driver: "saj-emanager" + version: "0.1.0" + ders: [pv, battery, meter] + control: false + firmware_versions: "" + notes: "Site controller in front of 1..N CHS2/CH2 C&I hybrids. Modbus TCP only, port 502; the register sheet marks RTU as not supported. Telemetry only, decoded from the community eManager/SEC-C workbook. Not tested on hardware. First check is Castellum Örebro (3x CHS2 50 kW). Control 0x8400-0x8405 is documented and not written." - name: "SMA" model_families: - name: "SMA Home Manager 2.0" diff --git a/drivers/lua/saj-emanager.lua b/drivers/lua/saj-emanager.lua new file mode 100644 index 0000000..d1f4061 --- /dev/null +++ b/drivers/lua/saj-emanager.lua @@ -0,0 +1,599 @@ +-- SAJ eManager C1 Pro site controller. +-- Emits: PV, Battery (only when the aggregate says a pack is there), Meter +-- Protocol: Modbus TCP only, HOLDING registers (FC 0x03), port 502, unit 1 +-- +-- The interface sheet marks Modbus RTU as "Not supported". This driver has +-- no serial settings and does not speak RTU. The catalog protocol field is +-- still "modbus" because that is the only Modbus value the manifest allows. +-- +-- Register map (community, not an official SAJ publication): +-- Modbus_registers_eManager.xlsx, posted by remkoteselle on +-- github.com/stanus74/home-assistant-saj-h2-modbus discussion #182. +-- Sheets: Interface spec, EMS Mapping table, Real-time Data Table, +-- EMS Energy Dispatch Control, sec-c Anti-Backflow Control. +-- A text dump of that workbook is what this file was decoded from. +-- SAJ's own portal copies are not linked here; a document behind a login +-- cannot be watched. +-- +-- Cross-checks, not the map this driver reads: +-- * Eniris SmartgridOne, "SAJ eManager-C1 Pro" (still marked in testing): +-- docs.eniris.com — TCP 502, unit 1, and the voltage/frequency note below. +-- * OpenEMS io.openems.edge.saj (xeno27/openems pull 2), the CH2 PCS +-- map from SAJ "Protocol for CH2 remote EMS control". Same 0x8400-0x8405 +-- control block. Direct CH2 realtime is 0x7B04-0x7C88, which this +-- controller map does not use. Not hardware-validated there either. +-- * The H2 holding map in drivers/lua/saj.lua, for sign and word order. +-- +-- This build is read-only and has not been run on hardware. The first site +-- to check is Castellum Örebro (3x CHS2 50 kW behind one eManager). +-- +-- ═══ Addresses (holding, hex is the sheet's number) ═══════════════════════ +-- +-- 0x4A00 DeviceNum U16 connected devices, 0..21 +-- 0x4A01 per device, stride 12 words, up to 21: +-- SN 10 words, ASCII, high byte first +-- ID U16 Modbus unit id of that inverter +-- reserved U16 +-- The ID15 reserved row prints decimal 19234 next to hex 4AB4. +-- 0x4AB4 is 19124. The stride above follows the hex and the other +-- twenty rows, not that one decimal. +-- 0x848A system realtime, see the block layout below +-- 0x849E grid phase R, then S, then T. 8 words each. +-- 0x8500 energy counters, U32, magnification -2, unit kWh +-- 0x8550 parallInvPower is length 2 and the next row says reserve at +-- 0x8551. Those overlap. This driver stops at 0x854F. +-- +-- There is no eManager serial in this sheet. Identity is the first connected +-- inverter SN, plus DeviceNum. A blank first SN falls through to the next. +-- +-- ═══ Scaling (sheet "Magnification" column) ═══════════════════════════════ +-- +-- -1 × 0.1 grid voltage (V), grid current (A) +-- -2 × 0.01 frequency (Hz), SoC (%), energy (kWh) +-- -3 × 0.001 power factor (not emitted; no DER field) +-- blank on a watt register means the word is already watts +-- +-- int32 / uint32 are two words. The sheet does not state the word order. +-- ASSUMPTION: high word first, same as the H2 driver in this repository and +-- the SAJ Modbus PDFs. UNVERIFIED on an eManager. A swapped pair of a +-- negative power would be a huge value, not a sign flip of the real watts. +-- +-- ═══ Signs — UNVERIFIED on this map ═══════════════════════════════════════ +-- +-- The realtime sheet never says which direction is positive. Two neighbouring +-- sources disagree about how much to trust a SAJ power word: +-- +-- H2 (drivers/lua/saj.lua, evcc saj-h2): +-- PV magnitude, battery discharge-positive, grid import-positive. +-- eManager setpoints 0x8402 / 0x8405 (this sheet, not a measurement): +-- positive discharges to the grid, negative charges / imports. +-- CH2 direct map (OpenEMS): the power word is NOT trusted. Direction +-- registers 0x7BEF / 0x7BF0 decide. This sheet has no such registers. +-- +-- What this driver does, in one place each, until Castellum says otherwise: +-- pv.W TotalPVPower is uint32. Treated as a magnitude and negated. +-- battery.W assumed discharge-positive, negated to site charge-positive. +-- meter.W assumed import-positive, matching H2 and the site rule, so +-- the value is not negated. Phase watts follow the same rule. +-- phase amps assumed the same way (positive into the site). Not negated. +-- +-- Load (0x8493) and inverter (0x848D) power are int32 with no stated sign. +-- They are metrics under the vendor's own sign, not DER fields. +-- +-- ═══ Voltage and frequency may be summed — the sheet does not say so ═════ +-- +-- Eniris, for this exact product, says the eManager returns phase voltage +-- and frequency as the SUM across connected inverters (4 inverters can read +-- 200 Hz) and that the controller divides by the inverter count. Power is +-- the combined total and must not be divided. Current is not mentioned. +-- +-- The register sheet describes those cells as ordinary phase quantities +-- (magnification -1 V, -2 Hz). It does not mention a sum. Dividing every +-- site by DeviceNum would turn a true 230 V into 77 V on a 3-inverter plant. +-- +-- So this driver divides voltage or frequency only when both are true: +-- the sheet's own scale is implausible for one phase, and +-- DeviceNum was read (2..21) and the quotient lands in a normal band +-- (about 180..280 V, 45..65 Hz). +-- A 230 V / 50 Hz reading is left alone, even when three inverters are +-- connected. A 690 V / 150 Hz reading with DeviceNum = 3 becomes 230 V / +-- 50 Hz. Power, current, energy and rated power are never divided. +-- Confirm at Castellum which of the two a 3x CHS2 plant actually returns. +-- +-- ═══ Battery presence ═════════════════════════════════════════════════════ +-- +-- A hybrid plant and a PV-only plant (the home manual also lists R5/R6) +-- share this map. paralltotalcap / parallUsercap at 0, SoC at 0 and battery +-- power at 0 do not mean an empty pack: nothing on the map has said a pack +-- exists. Emitting SoC 0 in that case is an empty battery the planner can +-- charge. The battery DER is emitted only when capacity is non-zero, SoC is +-- in 0.01%..100.00%, or battery power is non-zero. A real 0% pack that still +-- reports capacity is emitted, SoC and all. +-- +-- The capacity unit on the sheet is "K", not kWh and not Wh. The raw word +-- is a presence signal and a metric. It is not converted. +-- +-- ═══ Energy direction ═════════════════════════════════════════════════════ +-- +-- PV generation, battery charge and battery discharge totals are named on +-- the sheet and are copied onto the DER. FeedIn (0x852E) and Sell (0x8536) +-- are not: SAJ uses both words for both directions in different PDFs. They +-- are metrics under the vendor names, not meter import/export. +-- +-- ═══ Control 0x8400-0x8405 — NOT IMPLEMENTED ══════════════════════════════ +-- +-- EMS sheet (eManager), R/W: +-- 0x8400 EMSEnable U16 +-- 0 invalid / inverter's own mode +-- 1 inverter-power dispatch, follows 0x8402 +-- 2 battery-power dispatch, follows 0x8405 +-- (the sheet says SEC-C does not support 2) +-- 0x8401 EMSKeepTime U16 seconds, default 10, range 0..60000. +-- Heartbeat. On timeout the machine outputs 0. +-- 0x8402 EMSINVPRef I16 magnification -4 unit % range -11000..11000 +-- Positive: discharge / export toward the target. +-- Negative: import and charge toward the target. +-- 0x8403 EMSBATChargCurrentLimit U16 magnification -1 A +-- 0x8404 EMSBATDisChargCurrentLimit U16 magnification -1 A +-- Both marked "Temporarily Closed" on the sheet. +-- 0x8405 EMSBATPRef I16 same scaling and sign as 0x8402, battery target. +-- +-- Scaling is unverified. Magnification -4 means ×10^-4, so raw 10000 = 1.00 +-- and raw 11000 = 1.10. Read as a fraction of rated power, that is 100% and +-- 110%, i.e. 0.01 percentage points per step. The unit column says "%" +-- which would be the same limit if the step were 0.01% (magnification -2). +-- Both readings of the sheet put the documented stop at ±110% of rated. +-- OpenEMS CH2 states the same numbers from the unpublished CH2 EMS protocol +-- (10000 = 100%, ±110%, positive discharges) and was not run on hardware. +-- +-- SEC-C anti-backflow reuses 0x8400-0x8404 with different meanings (0 off, +-- 1 phase power limit, 2 phase current limit, 3 total power limit; 0x8402 +-- is then a ±110% grid limit at magnification -2). Writing the EMS encoding +-- to a SEC-C, or the reverse, would select the wrong mode. This driver +-- targets the eManager EMS sheet and writes neither block. +-- +-- A future control path has to prove, on a named eManager and firmware: +-- held zero, charge, discharge, heartbeat refresh, and a release back to +-- EMSEnable 0. Until then driver_command refuses and driver_default_mode +-- does not write. + +DRIVER = { + id = "saj-emanager", + name = "SAJ eManager C1 Pro", + manufacturer = "SAJ", + version = "0.1.0", + host_api_min = 1, + host_api_max = 1, + protocols = { "modbus" }, + capabilities = { "pv", "battery", "meter" }, + read_only = true, + description = "SAJ eManager C1 Pro site controller (Modbus TCP only, port 502; RTU is not supported) in front of CHS2/CH2 hybrids. Aggregate PV, battery and grid telemetry. Read-only. Not tested on hardware.", + homepage = "https://www.saj-electric.com", + authors = { "Sourceful Labs AB" }, + tested_models = { "eManager C1 Pro" }, + verification_status = "experimental", + verification_notes = "Decoded from the community eManager/SEC-C register workbook on home-assistant-saj-h2-modbus discussion 182, cross-checked against Eniris and the OpenEMS CH2 module. Not tested on hardware. Control registers 0x8400-0x8405 are documented and not written. First site to check: Castellum Örebro.", + connection_defaults = { + port = 502, + unit_id = 1, + }, +} + +PROTOCOL = "modbus" + +---------------------------------------------------------------------------- +-- Register map +---------------------------------------------------------------------------- + +local REG_DEVICE_NUM = 0x4A00 +local REG_SLOT0 = 0x4A01 +local SLOT_STRIDE = 12 +local SN_WORDS = 10 +local MAX_DEVICES = 21 +-- Stay under the usual 125-word Modbus ceiling. 10 slots = 120 words. +local SLOTS_PER_READ = 10 + +-- 0x848A .. 0x849D. One read, 20 words. Layout is 1-based in the block. +local REG_SUMMARY = 0x848A +local SUMMARY_WORDS = 20 +local IDX_MODE = 1 +local IDX_RATED = 2 -- uint32 +local IDX_INV = 4 -- int32 TotalInvPowerWatt +local IDX_PV = 6 -- uint32 TotalPVPower +local IDX_BAT = 8 -- int32 TotalBatteryPower +local IDX_LOAD = 10 -- int32 +local IDX_SOC = 12 -- uint16 × 0.01 % +local IDX_GRID = 13 -- int32 TotalGridPowerWatt +local IDX_GEN = 15 -- int32 +local IDX_CAP = 17 -- uint32 paralltotalcap, unit "K" +local IDX_USERCAP = 19 -- uint32 parallUsercap, unit "K" + +-- 0x849E .. 0x84B5. R, S, T. 8 words: V, I, f, P, S, PF. +local REG_PHASES = 0x849E +local PHASES_WORDS = 24 +local PHASE_WORDS = 8 + +-- 0x8500 .. 0x854F. Pairs of uint32 × 0.01 kWh. Pair 0 is 0x8500. +local REG_ENERGY = 0x8500 +local ENERGY_WORDS = 80 +local PAIR_TOTAL_PV = 3 -- 0x8506 +local PAIR_TOTAL_BAT_CHG = 7 -- 0x850E +local PAIR_TOTAL_BAT_DIS = 11 -- 0x8516 +local PAIR_TOTAL_FEED_IN = 23 -- 0x852E +local PAIR_TOTAL_SELL = 27 -- 0x8536 + +-- MPVMode 5 is "Fault" on the sheet. +local MODE_FAULT = 5 + +-- One-phase bands for the Eniris sum heuristic. See the header. +local VOLT_SUSPECT = 320 +local VOLT_OK_LO = 180 +local VOLT_OK_HI = 280 +local HZ_SUSPECT = 70 +local HZ_OK_LO = 45 +local HZ_OK_HI = 65 + +local PHASE_NAMES = { "L1", "L2", "L3" } + +---------------------------------------------------------------------------- +-- Decoding +---------------------------------------------------------------------------- + +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 + +local function scaled(raw, factor) + if raw == nil then return nil end + return finite(raw * factor) +end + +-- 0xFFFFFFFF is not a capacity or an energy total. Leave it out. +local function u32_at(regs, index) + local hi, lo = regs[index], regs[index + 1] + if hi == nil or lo == nil then return nil end + if hi == 0xFFFF and lo == 0xFFFF then return nil end + return host.decode_u32_be(hi, lo) +end + +-- 0x7FFFFFFF and 0x80000000 are not a site power. The sheet does not name +-- them as sentinels; they are rejected because they are not a measurement. +local function i32_at(regs, index) + local hi, lo = regs[index], regs[index + 1] + if hi == nil or lo == nil then return nil end + local raw = host.decode_i32_be(hi, lo) + if raw == 2147483647 or raw == -2147483648 then return nil end + return raw +end + +-- 0.01 kWh → Wh. A decoded 0 stays 0: the device said zero. +local function u32_wh(regs, index) + local raw = u32_at(regs, index) + if raw == nil then return nil end + return finite(raw * 10) +end + +local function pair_wh(regs, pair) + return u32_wh(regs, pair * 2 + 1) +end + +-- SoC: uint16 × 0.01 percent → fraction 0..1. 0xFFFF and anything above +-- 100.00% are not a state of charge. +local function soc_fraction(raw) + if raw == nil or raw == 0xFFFF or raw > 10000 then return nil end + return raw / 10000 +end + +-- ASSUMPTION, unverified: grid import is already positive. +local function grid_watts(vendor) + return vendor +end + +-- ASSUMPTION, unverified: battery discharge is positive on the wire. +local function battery_watts(vendor) + if vendor == nil then return nil end + return -vendor +end + +-- Divide only when the sheet scale is implausible AND DeviceNum explains it. +-- Returns the value to publish, and whether a divide was applied. +local function unsum_if_indicated(value, device_count, suspect_above, ok_lo, ok_hi) + if value == nil then return nil, false end + if value == 0 then return 0, false end + if device_count ~= nil and device_count >= 2 and device_count <= MAX_DEVICES + and value > suspect_above then + local corrected = finite(value / device_count) + if corrected ~= nil and corrected >= ok_lo and corrected <= ok_hi then + return corrected, true + end + end + return value, false +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. Three misses, then stop until restart. +-- A short reply is a miss: the missing words are nil, never a filled-in 0. +-- +-- The summary block does not go through here. When it fails the driver +-- emits no DER at all, so a blip cannot silence the plant for the session +-- and a permanent failure does not claim the device is fine. +---------------------------------------------------------------------------- + +local GIVE_UP_AFTER = 3 +local read_failures = {} +local announced = false + +local function complete(regs, count) + if regs == nil then return false end + for i = 1, count do + if regs[i] == nil then return false end + end + return true +end + +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 complete(regs, count) then + read_failures[addr] = nil + return regs + end + local failures = (read_failures[addr] or 0) + 1 + read_failures[addr] = failures + if failures == GIVE_UP_AFTER then + host.log("info", string.format( + "SAJ eManager: register %d did not answer %d times; leaving it " .. + "alone until restart", addr, GIVE_UP_AFTER)) + end + return nil +end + +local function required_read(addr, count, kind) + local ok, regs = pcall(host.modbus_read, addr, count, kind) + if ok and complete(regs, count) then return regs end + return nil +end + +---------------------------------------------------------------------------- +-- Identity. DeviceNum decides how many slots exist; we do not scan 21. +---------------------------------------------------------------------------- + +local function read_device_count() + local regs = probe_read(REG_DEVICE_NUM, 1, "holding") + if not regs then return nil end + local n = regs[1] + if n == nil or n == 0xFFFF or n > MAX_DEVICES then return nil end + return n +end + +local function decode_slots(regs, n) + local devices = {} + for i = 0, n - 1 do + local base = i * SLOT_STRIDE + local sn = host.decode_string(regs, base + 1, SN_WORDS) + if sn == "" then sn = nil end + devices[#devices + 1] = { + sn = sn, + id = regs[base + SN_WORDS + 1], + } + end + return devices +end + +local function read_slots(count) + if count == nil or count < 1 then return nil end + local devices = {} + local done = 0 + while done < count do + local n = math.min(count - done, SLOTS_PER_READ) + local addr = REG_SLOT0 + done * SLOT_STRIDE + local regs = probe_read(addr, n * SLOT_STRIDE, "holding") + if not regs then return nil end + local part = decode_slots(regs, n) + for _, device in ipairs(part) do + devices[#devices + 1] = device + end + done = done + n + end + return devices +end + +local function first_sn(devices) + if not devices then return nil end + for _, device in ipairs(devices) do + if device.sn then return device.sn end + end + return nil +end + +local function note_identity(device_count, devices) + local sn = first_sn(devices) + if sn then host.set_sn(sn) end + if device_count ~= nil then + host.emit_metric("saj_emanager_device_count", device_count) + end + if announced or device_count == nil then return end + announced = true + host.log("info", string.format( + "SAJ eManager: DeviceNum=%d first SN=%s", + device_count, sn or "(none)")) +end + +---------------------------------------------------------------------------- +-- Summary block → DER fields. Nil means the word did not decode. +---------------------------------------------------------------------------- + +local function battery_indicated(cap, user, soc_raw, bat_w) + if cap ~= nil and cap > 0 then return true end + if user ~= nil and user > 0 then return true end + if soc_raw ~= nil and soc_raw > 0 and soc_raw <= 10000 then return true end + if bat_w ~= nil and bat_w ~= 0 then return true end + return false +end + +local function decode_summary(regs) + local mode = regs[IDX_MODE] + if mode ~= nil and mode ~= 0xFFFF then + host.emit_metric("saj_emanager_mpv_mode", mode) + if mode == MODE_FAULT then + host.set_device_fault(true, "MPVMode 5 fault") + else + host.set_device_fault(false, "") + end + end + + local rated = u32_at(regs, IDX_RATED) + if rated ~= nil and rated > 0 then host.set_rated_w(rated) end + + local pv_w = u32_at(regs, IDX_PV) + local bat_w = i32_at(regs, IDX_BAT) + local grid_w = i32_at(regs, IDX_GRID) + local cap = u32_at(regs, IDX_CAP) + local user = u32_at(regs, IDX_USERCAP) + local soc_raw = regs[IDX_SOC] + + local inv_w = i32_at(regs, IDX_INV) + if inv_w ~= nil then host.emit_metric("saj_emanager_inv_w", inv_w) end + local load_w = i32_at(regs, IDX_LOAD) + if load_w ~= nil then host.emit_metric("saj_emanager_load_w", load_w) end + local gen_w = i32_at(regs, IDX_GEN) + if gen_w ~= nil and gen_w ~= 0 then + host.emit_metric("saj_emanager_gen_w", gen_w) + end + + local pv = {} + if pv_w ~= nil then pv.W = -pv_w end + + local meter = {} + if grid_w ~= nil then meter.W = grid_watts(grid_w) end + + local battery = nil + if battery_indicated(cap, user, soc_raw, bat_w) then + battery = {} + local site_bat = battery_watts(bat_w) + if site_bat ~= nil then battery.W = site_bat end + local soc = soc_fraction(soc_raw) + if soc ~= nil then battery.SoC_nom_fract = soc end + if cap ~= nil then + host.emit_metric("saj_emanager_battery_capacity_raw", cap) + end + if user ~= nil then + host.emit_metric("saj_emanager_battery_available_raw", user) + end + end + + return pv, meter, battery +end + +local function apply_phases(meter, regs, device_count) + local divided = false + local hz + for i, name in ipairs(PHASE_NAMES) do + local b = (i - 1) * PHASE_WORDS + local volts = scaled(regs[b + 1], 0.1) + local amps = nil + if regs[b + 2] ~= nil then + amps = scaled(host.decode_i16(regs[b + 2]), 0.1) + end + local freq = scaled(regs[b + 3], 0.01) + local watts = i32_at(regs, b + 4) + local v_out, v_div = unsum_if_indicated( + volts, device_count, VOLT_SUSPECT, VOLT_OK_LO, VOLT_OK_HI) + local f_out, f_div = unsum_if_indicated( + freq, device_count, HZ_SUSPECT, HZ_OK_LO, HZ_OK_HI) + if v_div or f_div then divided = true end + if v_out ~= nil then meter[name .. "_V"] = v_out end + if amps ~= nil then meter[name .. "_A"] = amps end + local site_w = grid_watts(watts) + if site_w ~= nil then meter[name .. "_W"] = site_w end + if i == 1 then hz = f_out end + end + if hz ~= nil then meter.Hz = hz end + return divided +end + +local function apply_energy(pv, battery, regs) + local pv_wh = pair_wh(regs, PAIR_TOTAL_PV) + if pv_wh ~= nil then pv.lifetime_wh = pv_wh end + if battery then + local chg = pair_wh(regs, PAIR_TOTAL_BAT_CHG) + local dis = pair_wh(regs, PAIR_TOTAL_BAT_DIS) + if chg ~= nil then battery.total_charge_Wh = chg end + if dis ~= nil then battery.total_discharge_Wh = dis end + end + -- Vendor names, deliberately not import_wh / export_wh. + local feed = pair_wh(regs, PAIR_TOTAL_FEED_IN) + local sell = pair_wh(regs, PAIR_TOTAL_SELL) + if feed ~= nil then host.emit_metric("saj_emanager_vendor_feed_in_wh", feed) end + if sell ~= nil then host.emit_metric("saj_emanager_vendor_sell_wh", sell) end +end + +local function emit_der(kind, fields) + if next(fields) ~= nil then host.emit(kind, fields) end +end + +---------------------------------------------------------------------------- +-- Entry points +---------------------------------------------------------------------------- + +function driver_init(config) + host.set_make("SAJ") + host.set_model("eManager C1 Pro") +end + +function driver_poll() + local device_count = read_device_count() + local devices = read_slots(device_count) + note_identity(device_count, devices) + + local summary = required_read(REG_SUMMARY, SUMMARY_WORDS, "holding") + if not summary then return 5000 end + + local pv, meter, battery = decode_summary(summary) + + local phases = probe_read(REG_PHASES, PHASES_WORDS, "holding") + local divided = false + if phases then + divided = apply_phases(meter, phases, device_count) + end + if divided and device_count ~= nil then + host.emit_metric("saj_emanager_phase_scale_divisor", device_count) + elseif phases and device_count ~= nil and device_count >= 1 then + host.emit_metric("saj_emanager_phase_scale_divisor", 1) + end + + local energy = probe_read(REG_ENERGY, ENERGY_WORDS, "holding") + if energy then + apply_energy(pv, battery, energy) + end + + emit_der("pv", pv) + emit_der("meter", meter) + if battery then emit_der("battery", battery) end + + return 5000 +end + +function driver_command(action, power_w, cmd) + if action == "init" or action == "deinit" then + return true + end + host.log("warn", "SAJ eManager: control is not enabled (action=" .. + tostring(action) .. ")") + return false +end + +function driver_default_mode() + -- Read-only: nothing was taken, so there is nothing to release. + -- 0x8400 is not written back to 0 from here. +end + +function driver_cleanup() + announced = false + read_failures = {} +end diff --git a/drivers/tests/test_saj-emanager.py b/drivers/tests/test_saj-emanager.py new file mode 100644 index 0000000..787f747 --- /dev/null +++ b/drivers/tests/test_saj-emanager.py @@ -0,0 +1,541 @@ +"""SAJ eManager C1 Pro: aggregate Modbus TCP telemetry, no fabricated zeros. + +The register sheet is the community workbook on +stanus74/home-assistant-saj-h2-modbus discussion 182. Nothing here was read +off a live controller. The numbers below are the sheet's own scales applied +to a fixture, including the two sign assumptions (battery discharge-positive, +grid import-positive) and high-word-first int32. +""" + +from __future__ import annotations + +import math +import re +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-emanager.lua" + +pytestmark = pytest.mark.skipif( + not LUA.exists(), reason="run make check to build ./lua55") + +REG_DEVICE_NUM = 0x4A00 +REG_SLOT0 = 0x4A01 +REG_SUMMARY = 0x848A +REG_PHASES = 0x849E +REG_ENERGY = 0x8500 +REG_CONTROL = 0x8400 + +SERIALS = ( + "CHS2TEST0000000001", + "CHS2TEST0000000002", + "CHS2TEST0000000003", +) + +# Sheet scales, raw register units. Signs are the vendor's, not the site's. +PV_W = 123456 # uint32 magnitude, high word first +BAT_W_VENDOR = -6000 # discharge-positive: negative means charging +GRID_W_VENDOR = -2500 # import-positive: negative means export +INV_W_VENDOR = 42000 +LOAD_W_VENDOR = 9000 +SOC_RAW = 7325 # 0.01 % → 73.25 % +RATED_W = 150000 +CAP_RAW = 200 # unit on the sheet is "K", not Wh +USER_RAW = 146 +PV_ENERGY_RAW = 123456 # 0.01 kWh +BAT_CHARGE_RAW = 50000 +BAT_DISCHARGE_RAW = 40000 +FEED_IN_RAW = 111 +SELL_RAW = 222 +DEVICE_COUNT = 3 + +# One phase, already on the sheet's scale. Must NOT be divided by 3. +L1_V_RAW = 2304 # 230.4 V +L1_A_RAW = 105 # 10.5 A +L2_A_RAW = -15 # -1.5 A +L1_HZ_RAW = 5002 # 50.02 Hz +L1_W = -800 +L2_W = -700 +L3_W = -1000 + + +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 _u32_regs(value: int) -> list[int]: + return [(value >> 16) & 0xFFFF, value & 0xFFFF] + + +def _i32_regs(value: int) -> list[int]: + raw = value & 0xFFFFFFFF + return [(raw >> 16) & 0xFFFF, raw & 0xFFFF] + + +def _i16(value: int) -> int: + return value & 0xFFFF + + +def _lua_list(values) -> str: + return "{" + ", ".join(str(int(v)) for v in values) + "}" + + +def _put_pair(buf: list[int], pair: int, value: int) -> None: + hi, lo = _u32_regs(value) + buf[pair * 2] = hi + buf[pair * 2 + 1] = lo + + +def fixture_registers(*, pack: str = "yes", summed: bool = False, + count: int = DEVICE_COUNT) -> str: + """Three CHS2s behind one eManager, unless count says otherwise. + + pack: "yes" (capacity + SoC + power), "no" (all zero), "power" (only + battery watts), "soc_sentinel" (capacity, SoC word 0xFFFF). + summed: phase volts and hertz are DeviceNum times a normal phase, which + is the Eniris observation the sheet itself does not document. + """ + slots: list[int] = [] + n_slots = count if 1 <= count <= 21 else 0 + for i in range(n_slots): + slots.extend(_ascii_regs(SERIALS[i], 10)) + slots.append(i + 1) # inverter Modbus id + slots.append(0) # reserved + + summary = [0] * 20 + summary[0] = 2 # MPVMode grid-connected + summary[1:3] = _u32_regs(RATED_W) + summary[3:5] = _i32_regs(INV_W_VENDOR) + summary[5:7] = _u32_regs(PV_W) + summary[9:11] = _i32_regs(LOAD_W_VENDOR) + summary[12:14] = _i32_regs(GRID_W_VENDOR) + # generator stays 0: no metric + if pack == "no": + summary[7:9] = _i32_regs(0) + summary[11] = 0 + summary[16:18] = _u32_regs(0) + summary[18:20] = _u32_regs(0) + elif pack == "power": + summary[7:9] = _i32_regs(BAT_W_VENDOR) + summary[11] = 0 + summary[16:18] = _u32_regs(0) + summary[18:20] = _u32_regs(0) + elif pack == "soc_sentinel": + summary[7:9] = _i32_regs(BAT_W_VENDOR) + summary[11] = 0xFFFF + summary[16:18] = _u32_regs(CAP_RAW) + summary[18:20] = _u32_regs(USER_RAW) + else: + summary[7:9] = _i32_regs(BAT_W_VENDOR) + summary[11] = SOC_RAW + summary[16:18] = _u32_regs(CAP_RAW) + summary[18:20] = _u32_regs(USER_RAW) + + hz_raw = L1_HZ_RAW * count if summed and count > 1 else L1_HZ_RAW + phases: list[int] = [] + for phase, watts in enumerate((L1_W, L2_W, L3_W)): + amps = (L1_A_RAW, L2_A_RAW, 200)[phase] + # Each phase's own voltage, summed across inverters when `summed`. + volts = L1_V_RAW + phase + if summed and count > 1: + volts *= count + phases.extend([ + volts, + _i16(amps), + hz_raw, + *_i32_regs(watts), + 0, 0, # apparent power, unused + 1000, # power factor × 0.001, unused + ]) + + energy = [0] * 80 + _put_pair(energy, 3, PV_ENERGY_RAW) # 0x8506 total PV + _put_pair(energy, 7, BAT_CHARGE_RAW) # 0x850E + _put_pair(energy, 11, BAT_DISCHARGE_RAW) # 0x8516 + _put_pair(energy, 23, FEED_IN_RAW) # 0x852E + _put_pair(energy, 27, SELL_RAW) # 0x8536 + + lines = [ + f"host._modbus_registers.holding[{REG_DEVICE_NUM}] = {{{count}}}", + f"host._modbus_registers.holding[{REG_SUMMARY}] = {_lua_list(summary)}", + f"host._modbus_registers.holding[{REG_PHASES}] = {_lua_list(phases)}", + f"host._modbus_registers.holding[{REG_ENERGY}] = {_lua_list(energy)}", + ] + if slots: + lines.append( + f"host._modbus_registers.holding[{REG_SLOT0}] = {_lua_list(slots)}") + return "\n".join(lines) + + +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 = f''' +local function last(t) return t and t[#t] or nil end +local function field(obj, key) + if obj == nil or obj[key] == nil then return "nil" end + return tostring(obj[key]) +end +local function metric(name) + local m = host._metrics[name] + if m == nil or m.value == nil then return "nil" end + return tostring(m.value) +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)) +print("PV_W " .. field(pv, "W")) +print("PV_WH " .. field(pv, "lifetime_wh")) +print("BAT_W " .. field(bat, "W")) +print("BAT_SOC " .. field(bat, "SoC_nom_fract")) +print("BAT_CHG " .. field(bat, "total_charge_Wh")) +print("BAT_DIS " .. field(bat, "total_discharge_Wh")) +print("MET_W " .. field(met, "W")) +print("MET_HZ " .. field(met, "Hz")) +print("MET_L1V " .. field(met, "L1_V")) +print("MET_L1A " .. field(met, "L1_A")) +print("MET_L1W " .. field(met, "L1_W")) +print("MET_L2A " .. field(met, "L2_A")) +print("MET_L2W " .. field(met, "L2_W")) +print("MET_L3V " .. field(met, "L3_V")) +print("MET_L3W " .. field(met, "L3_W")) +print("MET_IMPORT " .. field(met, "total_import_Wh")) +print("MET_EXPORT " .. field(met, "total_export_Wh")) +print("MAKE " .. tostring(host._make)) +print("MODEL " .. tostring(host._model)) +print("SN " .. tostring(host._sn)) +print("RATED " .. tostring(host._rated_w)) +print("FAULT " .. tostring(host._faulted)) +print("COUNT " .. metric("saj_emanager_device_count")) +print("DIVISOR " .. metric("saj_emanager_phase_scale_divisor")) +print("LOAD " .. metric("saj_emanager_load_w")) +print("INV " .. metric("saj_emanager_inv_w")) +print("GEN " .. metric("saj_emanager_gen_w")) +print("CAP " .. metric("saj_emanager_battery_capacity_raw")) +print("FEED " .. metric("saj_emanager_vendor_feed_in_wh")) +print("SELL " .. metric("saj_emanager_vendor_sell_wh")) +local slot_reads, slot_count, control_reads, bad_kind = 0, 0, 0, 0 +for _, call in ipairs(host._calls) do + if call.func == "modbus_read" then + if call.args[1] == {REG_SLOT0} then + slot_reads = slot_reads + 1 + slot_count = call.args[2] + end + if call.args[1] == {REG_CONTROL} then + control_reads = control_reads + 1 + end + if call.args[3] ~= "holding" then bad_kind = bad_kind + 1 end + end +end +print("SLOT_READS " .. tostring(slot_reads)) +print("SLOT_COUNT " .. tostring(slot_count)) +print("CONTROL_READS " .. tostring(control_reads)) +print("BAD_KIND " .. tostring(bad_kind)) +print("WRITES " .. tostring(host._modbus_write_attempts)) +''' + + +def poll_once(extra: str = "", **fixture) -> dict[str, str]: + return run_lua(f''' +{fixture_registers(**fixture)} +{extra} +dofile("{DRIVER}") +driver_init({{}}) +local interval = driver_poll() +print("INTERVAL " .. tostring(interval)) +{REPORT} +''') + + +def test_three_inverter_plant_in_site_convention(): + out = poll_once() + + assert out["INTERVAL"] == "5000" + assert out["MAKE"] == "SAJ" + assert out["MODEL"] == "eManager C1 Pro" + assert out["SN"] == SERIALS[0] + assert out["COUNT"] == "3" + assert out["RATED"] == str(RATED_W) + assert out["FAULT"] == "false" + assert out["BAD_KIND"] == "0" + assert out["CONTROL_READS"] == "0" + assert out["WRITES"] == "0" + # One read of the three slots, not a scan of all 21. + assert out["SLOT_READS"] == "1" + assert out["SLOT_COUNT"] == str(DEVICE_COUNT * 12) + + assert out["PV_EMITS"] == "1" + assert float(out["PV_W"]) == -PV_W + assert float(out["PV_WH"]) == PV_ENERGY_RAW * 10 + + # Vendor discharge-positive charging (-6000) flips to site +6000. + assert out["BAT_EMITS"] == "1" + assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert math.isclose(float(out["BAT_SOC"]), SOC_RAW / 10000, rel_tol=1e-6) + assert float(out["BAT_CHG"]) == BAT_CHARGE_RAW * 10 + assert float(out["BAT_DIS"]) == BAT_DISCHARGE_RAW * 10 + assert out["CAP"] == str(CAP_RAW) + + # Vendor import-positive export stays negative. Not divided by 3. + assert out["MET_EMITS"] == "1" + assert float(out["MET_W"]) == GRID_W_VENDOR + assert math.isclose(float(out["MET_HZ"]), L1_HZ_RAW / 100, rel_tol=1e-6) + assert math.isclose(float(out["MET_L1V"]), L1_V_RAW / 10, rel_tol=1e-6) + assert math.isclose(float(out["MET_L1A"]), L1_A_RAW / 10, rel_tol=1e-6) + assert math.isclose(float(out["MET_L2A"]), L2_A_RAW / 10, rel_tol=1e-6) + assert float(out["MET_L1W"]) == L1_W + assert float(out["MET_L2W"]) == L2_W + assert float(out["MET_L3W"]) == L3_W + assert math.isclose(float(out["MET_L3V"]), (L1_V_RAW + 2) / 10, rel_tol=1e-6) + # Sheet scale was already one phase, so the divisor stays 1. + assert out["DIVISOR"] == "1" + # FeedIn / Sell stay vendor metrics. They are not import and export. + assert out["MET_IMPORT"] == "nil" + assert out["MET_EXPORT"] == "nil" + assert float(out["FEED"]) == FEED_IN_RAW * 10 + assert float(out["SELL"]) == SELL_RAW * 10 + assert float(out["LOAD"]) == LOAD_W_VENDOR + assert float(out["INV"]) == INV_W_VENDOR + assert out["GEN"] == "nil" + + +def test_summed_voltage_and_frequency_are_divided_power_is_not(): + """Eniris: 3 inverters can report 3×230 V and 3×50 Hz. Watts stay summed.""" + out = poll_once(summed=True) + + assert math.isclose(float(out["MET_L1V"]), L1_V_RAW / 10, rel_tol=1e-6) + assert math.isclose(float(out["MET_HZ"]), L1_HZ_RAW / 100, rel_tol=1e-6) + assert math.isclose(float(out["MET_L3V"]), (L1_V_RAW + 2) / 10, rel_tol=1e-6) + assert out["DIVISOR"] == "3" + # Current and power are not part of that correction. + assert math.isclose(float(out["MET_L1A"]), L1_A_RAW / 10, rel_tol=1e-6) + assert float(out["MET_L1W"]) == L1_W + assert float(out["PV_W"]) == -PV_W + assert float(out["MET_W"]) == GRID_W_VENDOR + + +def test_implausible_voltage_that_does_not_divide_cleanly_is_left_alone(): + """400 V / 3 is not a phase voltage, so the sheet scale is published.""" + out = poll_once( + f"host._modbus_registers.holding[{REG_PHASES}][1] = 4000") + assert math.isclose(float(out["MET_L1V"]), 400.0, rel_tol=1e-6) + assert out["DIVISOR"] == "1" + + +def test_sum_is_not_guessed_when_device_count_did_not_answer(): + out = poll_once( + f'host._modbus_read_fail_addresses[{REG_DEVICE_NUM}] = "timeout"\n' + f"host._modbus_registers.holding[{REG_PHASES}][3] = 15000") + assert out["COUNT"] == "nil" + assert out["DIVISOR"] == "nil" + assert out["SN"] == "nil" + assert out["SLOT_READS"] == "0" + # 150.00 Hz stays 150.00 Hz. Inventing a divisor of 3 would hide that. + assert math.isclose(float(out["MET_HZ"]), 150.0, rel_tol=1e-6) + assert out["PV_EMITS"] == "1" + + +def test_no_battery_when_capacity_soc_and_power_are_zero(): + out = poll_once(pack="no") + assert out["BAT_EMITS"] == "0" + assert out["CAP"] == "nil" + assert out["PV_EMITS"] == "1" + assert out["MET_EMITS"] == "1" + assert float(out["PV_W"]) == -PV_W + + +def test_battery_from_power_alone(): + """A non-zero battery watt is a pack, even when SoC and capacity read 0.""" + out = poll_once(pack="power") + assert out["BAT_EMITS"] == "1" + assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert float(out["BAT_SOC"]) == 0.0 + + +def test_soc_sentinel_is_omitted(): + out = poll_once(pack="soc_sentinel") + assert out["BAT_EMITS"] == "1" + assert out["BAT_SOC"] == "nil" + assert float(out["BAT_W"]) == -BAT_W_VENDOR + + +def test_device_zero_is_emitted_a_failed_read_is_not(): + """Night-time 0 W is the device. A missed summary is silence, not 0 W.""" + zeros = poll_once( + f"host._modbus_registers.holding[{REG_SUMMARY}][6] = 0\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][7] = 0\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][13] = 0\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][14] = 0") + assert zeros["PV_EMITS"] == "1" + assert float(zeros["PV_W"]) == 0 + assert zeros["MET_EMITS"] == "1" + assert float(zeros["MET_W"]) == 0 + assert zeros["BAT_EMITS"] == "1" + + failed = poll_once( + f'host._modbus_read_fail_addresses[{REG_SUMMARY}] = "timeout"') + assert failed["PV_EMITS"] == "0" + assert failed["MET_EMITS"] == "0" + assert failed["BAT_EMITS"] == "0" + assert failed["PV_W"] == "nil" + assert failed["MET_W"] == "nil" + + +def test_short_summary_emits_nothing(): + out = poll_once( + f"host._modbus_read_short_counts[{REG_SUMMARY}] = 10") + assert out["PV_EMITS"] == "0" + assert out["MET_EMITS"] == "0" + assert out["BAT_EMITS"] == "0" + assert out["PV_W"] == "nil" + assert out["RATED"] == "nil" + + +def test_short_phase_block_does_not_zero_fill(): + out = poll_once( + f"host._modbus_read_short_counts[{REG_PHASES}] = 8") + assert out["MET_EMITS"] == "1" + assert float(out["MET_W"]) == GRID_W_VENDOR + assert out["MET_L1V"] == "nil" + assert out["MET_L1W"] == "nil" + assert out["MET_HZ"] == "nil" + assert out["DIVISOR"] == "nil" + + +def test_phase_failure_keeps_the_totals(): + out = poll_once( + f'host._modbus_read_fail_addresses[{REG_PHASES}] = "Illegal Data Address"') + assert float(out["MET_W"]) == GRID_W_VENDOR + assert out["MET_L1V"] == "nil" + assert float(out["PV_W"]) == -PV_W + + +def test_energy_failure_omits_counters(): + out = poll_once( + f'host._modbus_read_fail_addresses[{REG_ENERGY}] = "Illegal Data Address"') + assert float(out["PV_W"]) == -PV_W + assert out["PV_WH"] == "nil" + assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert out["BAT_CHG"] == "nil" + assert out["BAT_DIS"] == "nil" + assert out["FEED"] == "nil" + assert out["SELL"] == "nil" + + +def test_int32_sentinel_is_not_a_power(): + out = poll_once( + f"host._modbus_registers.holding[{REG_SUMMARY}][13] = 0x7FFF\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][14] = 0xFFFF") + assert out["MET_W"] == "nil" + assert float(out["MET_L1W"]) == L1_W + assert out["MET_EMITS"] == "1" + + +def test_zero_devices_does_not_scan_slots(): + out = poll_once(count=0) + assert out["COUNT"] == "0" + assert out["SLOT_READS"] == "0" + assert out["SN"] == "nil" + assert out["PV_EMITS"] == "1" + + +def test_sentinel_device_count_does_not_scan_slots(): + out = poll_once(count=0xFFFF) + assert out["COUNT"] == "nil" + assert out["SLOT_READS"] == "0" + + +def test_fault_mode_is_reported(): + out = poll_once( + f"host._modbus_registers.holding[{REG_SUMMARY}][1] = 5") + assert out["FAULT"] == "true" + assert out["PV_EMITS"] == "1" + + +def test_phase_absence_settles_and_keeps_pv(): + out = run_lua(f''' +{fixture_registers()} +host._modbus_read_fail_addresses[{REG_PHASES}] = "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] == {REG_PHASES} then + reads = reads + 1 + end +end +print("PHASE_READS " .. tostring(reads)) +print("PV_EMITS " .. tostring(host._emitted["pv"] and #host._emitted["pv"] or 0)) +''') + assert out["PHASE_READS"] == "3" + assert out["PV_EMITS"] == "1" + + +def test_command_and_default_mode_do_not_write(): + out = run_lua(f''' +{fixture_registers()} +dofile("{DRIVER}") +driver_init({{}}) +driver_poll() +local ret = driver_command("battery", 1000, {{}}) +local init_ret = driver_command("init", 0, {{}}) +local deinit_ret = driver_command("deinit", 0, {{}}) +driver_default_mode() +print("RET " .. tostring(ret)) +print("INIT " .. tostring(init_ret)) +print("DEINIT " .. tostring(deinit_ret)) +print("WRITES " .. tostring(host._modbus_write_attempts)) +''') + assert out["RET"] == "false" + assert out["INIT"] == "true" + assert out["DEINIT"] == "true" + assert out["WRITES"] == "0" + + +def _code_without_comments() -> str: + lines = [] + for line in DRIVER.read_text(encoding="utf-8").splitlines(): + lines.append(line.split("--", 1)[0]) + return "\n".join(lines) + + +def test_source_is_tcp_only_and_does_not_read_or_write_control(): + """The executable code never touches 0x8400. The comments do, on purpose.""" + text = DRIVER.read_text(encoding="utf-8") + code = _code_without_comments() + assert "RTU is not supported" in text + assert re.search(r"port\s*=\s*502", text) + # Mentioning the block in a string is the documentation. Reading or + # writing it is the bug. + assert not re.search(r"modbus_read\s*\([^)]*0x8400", code) + assert "modbus_write" not in code + assert "write_registers" not in code + assert "host.write" not in code + manifest = (ROOT / "manifests" / "saj-emanager.yaml").read_text(encoding="utf-8") + assert 'tier: community' in manifest + assert "control: false" in manifest + assert "RTU" in manifest diff --git a/index.yaml b/index.yaml index a0bdde4..e22dda5 100644 --- a/index.yaml +++ b/index.yaml @@ -535,6 +535,15 @@ drivers: control: false size_bytes: 10858 sha256: "38749fb157e0929c26c31ccb9e8013e3ee81f584349be1dd7cc60792b9e3adeb" + - name: "saj-emanager" + version: "0.1.0" + tier: community + protocol: modbus + connectivity: local + ders: [pv, battery, meter] + control: false + size_bytes: 24558 + sha256: "643df6850d158b005ca52e8a579295a18c26916fb1ab01c185adc91407e33a05" - name: "saj" version: "1.2.0" tier: community diff --git a/manifests/saj-emanager.yaml b/manifests/saj-emanager.yaml new file mode 100644 index 0000000..938fdfb --- /dev/null +++ b/manifests/saj-emanager.yaml @@ -0,0 +1,38 @@ +name: "saj-emanager" +version: "0.1.0" +tier: community +author: "Sourceful Labs AB" +protocol: modbus +connectivity: local +ders: [pv, battery, meter] +control: false +tested_devices: + - manufacturer: "SAJ" + model_family: "eManager C1 Pro" + variants: ["eManager C1 Pro"] + regions: [] + firmware_versions: "" + notes: "Site controller in front of 1..N CHS2/CH2 C&I hybrids. Modbus TCP only, port 502; the register sheet marks RTU as not supported. Telemetry only, decoded from the community eManager/SEC-C workbook. Not tested on hardware. First check is Castellum Örebro (3x CHS2 50 kW). Control 0x8400-0x8405 is documented and not written." + min_driver_version: "0.1.0" +upstream_docs: + - url: "https://github.com/stanus74/home-assistant-saj-h2-modbus/discussions/182" + title: "Community eManager/SEC-C Modbus workbook (discussion attachment, not an official SAJ map)" + kind: register_map + url_stability: volatile + - url: "https://docs.eniris.com/en/Controller/Devices/PV-hybrid-and-battery-inverters/SAJ" + title: "Eniris SmartgridOne SAJ eManager-C1 Pro connection notes" + kind: other + url_stability: stable + - url: "https://www.saj-electric.com/hubfs/Guides%20and%20Manuals/eManager/eManager_User_Manual_V3_en.pdf" + title: "SAJ eManager user manual V3 (home controller; LAN Modbus, not the C&I register map)" + kind: manual + url_stability: stable +min_host_version: "2.0.0" +size_bytes: 24558 +dkb_id: "" +sha256: "643df6850d158b005ca52e8a579295a18c26916fb1ab01c185adc91407e33a05" +signature: "" +bytecode_sha256: "" +bytecode_signature: "" +bytecode_size: 0 +changelog: "Read-only aggregate telemetry for the eManager C1 Pro over Modbus TCP. No control writes. Not tested on hardware." diff --git a/support-status.json b/support-status.json index 403c1de..078fb2f 100644 --- a/support-status.json +++ b/support-status.json @@ -1218,6 +1218,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.0", + "driver_id": "saj-emanager", + "targets": { + "blixt-l1": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + }, + "ftw-core": { + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" + } + } + }, { "catalog_source": true, "catalog_version": "1.2.0", From d74c3305204691380e41b46c7ccb08959551871a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Nov 2026 11:37:09 +0000 Subject: [PATCH 2/2] fix(saj-emanager): state site signs on every watt Generation stays negative. Battery charge is positive and discharge is negative; the H2 wire is the other way round, so 0x8491 is negated. Grid import stays positive. Vendor-only words are not DER fields. Signed-off-by: Cursor Agent Co-authored-by: David Mozart --- CHANGELOG.md | 21 ++++++++-- SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/saj-emanager.lua | 56 ++++++++++++++----------- drivers/tests/test_saj-emanager.py | 66 ++++++++++++++++++++++++------ index.yaml | 6 +-- manifests/saj-emanager.yaml | 8 ++-- support-status.json | 2 +- 8 files changed, 114 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 290d17d..9d21e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### saj-emanager 0.1.1 + +Emitted power uses the same signs as the other drivers. Generation is +negative. Battery power is positive when charging and negative when +discharging. Grid power is positive on import and negative on export. + +The H2 wire is discharge-positive on the battery, so `0x8491` is negated +once at the boundary. The H2 grid word is already import-positive, so +`0x8496` and the phase watts are not. Inverter, load and generator words +have no stated sign and stay on `*_vendor` metrics, off the DER streams. +Still unverified on an eManager. + ### saj-emanager 0.1.0 Read-only telemetry for a SAJ eManager C1 Pro in front of one or more CHS2/CH2 @@ -14,9 +26,12 @@ hybrids. Modbus TCP only, port 502. The register sheet marks RTU as not supporte value cannot be one phase and the quotient can. The register sheet does not say those registers are summed. Eniris does, and only for voltage and frequency. Power is never divided. -- Signs and 32-bit word order follow the H2 driver (high word first, battery - discharge-positive, grid import-positive). The eManager sheet does not state - either. Both are unverified. +- Emitted signs match the rest of the catalog. Generation is negative. + Battery power is positive when charging and negative when discharging + (the H2 wire is discharge-positive, so `0x8491` is negated). Grid power is + positive on import and negative on export (the H2 wire already is, so + `0x8496` is not negated). Word order is high word first. The eManager + sheet states none of this. All three are unverified. - `0x8400`–`0x8405` are documented, including the ±11000 setpoint scaling and the SEC-C map that reuses the same addresses, and are not written. - Not run on hardware. The first site to check is Castellum Örebro. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 8d46356..5339321 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -120,8 +120,8 @@ Catalog source is not proof that a target can install or run a driver. | pixii_pv | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | remne_p1ib | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| saj-emanager | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| saj-emanager | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| saj-emanager | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| saj-emanager | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | schneider_meter | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index d8939ae..31e9414 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1104,7 +1104,7 @@ manufacturers: protocols: - protocol: modbus driver: "saj-emanager" - version: "0.1.0" + version: "0.1.1" ders: [pv, battery, meter] control: false firmware_versions: "" diff --git a/drivers/lua/saj-emanager.lua b/drivers/lua/saj-emanager.lua index d1f4061..f6d9f20 100644 --- a/drivers/lua/saj-emanager.lua +++ b/drivers/lua/saj-emanager.lua @@ -58,27 +58,30 @@ -- the SAJ Modbus PDFs. UNVERIFIED on an eManager. A swapped pair of a -- negative power would be a huge value, not a sign flip of the real watts. -- --- ═══ Signs — UNVERIFIED on this map ═══════════════════════════════════════ +-- ═══ Signs ═════════════════════════════════════════════════════════════════ -- --- The realtime sheet never says which direction is positive. Two neighbouring --- sources disagree about how much to trust a SAJ power word: +-- Positive watts flow INTO the site. Same rule as every other driver here +-- (docs/WRITING-A-DRIVER.md, drivers/lua/saj.lua): +-- pv.W always negative — generation +-- battery.W positive = charging, negative = discharging +-- meter.W positive = import, negative = export -- --- H2 (drivers/lua/saj.lua, evcc saj-h2): --- PV magnitude, battery discharge-positive, grid import-positive. --- eManager setpoints 0x8402 / 0x8405 (this sheet, not a measurement): --- positive discharges to the grid, negative charges / imports. --- CH2 direct map (OpenEMS): the power word is NOT trusted. Direction --- registers 0x7BEF / 0x7BF0 decide. This sheet has no such registers. +-- The realtime sheet never says which direction is positive on the wire. +-- The conversion, in one place each: +-- TotalPVPower (0x848F) is uint32, a magnitude. Negated. +-- TotalBatteryPower (0x8491) is assumed discharge-positive, which is the +-- H2 register and what the 0x8405 setpoint text describes. Negated, so +-- a charging pack is positive here. UNVERIFIED on this aggregate word. +-- TotalGridPowerWatt (0x8496) and the per-phase watts are assumed +-- import-positive, which is the H2 grid word and already the site rule. +-- Not negated. Phase current follows that (positive into the site). +-- UNVERIFIED on this aggregate word. +-- CH2's own map does not trust the power word; it uses direction registers +-- this sheet does not have. -- --- What this driver does, in one place each, until Castellum says otherwise: --- pv.W TotalPVPower is uint32. Treated as a magnitude and negated. --- battery.W assumed discharge-positive, negated to site charge-positive. --- meter.W assumed import-positive, matching H2 and the site rule, so --- the value is not negated. Phase watts follow the same rule. --- phase amps assumed the same way (positive into the site). Not negated. --- --- Load (0x8493) and inverter (0x848D) power are int32 with no stated sign. --- They are metrics under the vendor's own sign, not DER fields. +-- Load (0x8493), inverter (0x848D) and generator (0x8498) power are int32 +-- with no stated sign. They are metrics named *_vendor and are not DER +-- fields, so a positive word there is not a site-signed watt. -- -- ═══ Voltage and frequency may be summed — the sheet does not say so ═════ -- @@ -161,7 +164,7 @@ DRIVER = { id = "saj-emanager", name = "SAJ eManager C1 Pro", manufacturer = "SAJ", - version = "0.1.0", + version = "0.1.1", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -286,12 +289,15 @@ local function soc_fraction(raw) return raw / 10000 end --- ASSUMPTION, unverified: grid import is already positive. +-- Site meter watts: positive imports, negative exports. +-- Wire assumption (H2, unverified here): already import-positive, so no flip. local function grid_watts(vendor) return vendor end --- ASSUMPTION, unverified: battery discharge is positive on the wire. +-- Site battery watts: positive charges, negative discharges. +-- Wire assumption (H2 and the 0x8405 text, unverified here): positive on +-- 0x8491 is discharge, so negate once. local function battery_watts(vendor) if vendor == nil then return nil end return -vendor @@ -457,16 +463,18 @@ local function decode_summary(regs) local user = u32_at(regs, IDX_USERCAP) local soc_raw = regs[IDX_SOC] + -- No stated sign on these three. Keep the raw word off the DER streams. local inv_w = i32_at(regs, IDX_INV) - if inv_w ~= nil then host.emit_metric("saj_emanager_inv_w", inv_w) end + if inv_w ~= nil then host.emit_metric("saj_emanager_inv_w_vendor", inv_w) end local load_w = i32_at(regs, IDX_LOAD) - if load_w ~= nil then host.emit_metric("saj_emanager_load_w", load_w) end + if load_w ~= nil then host.emit_metric("saj_emanager_load_w_vendor", load_w) end local gen_w = i32_at(regs, IDX_GEN) if gen_w ~= nil and gen_w ~= 0 then - host.emit_metric("saj_emanager_gen_w", gen_w) + host.emit_metric("saj_emanager_gen_w_vendor", gen_w) end local pv = {} + -- Generation is negative in every driver in this repository. if pv_w ~= nil then pv.W = -pv_w end local meter = {} diff --git a/drivers/tests/test_saj-emanager.py b/drivers/tests/test_saj-emanager.py index 787f747..a8f79fb 100644 --- a/drivers/tests/test_saj-emanager.py +++ b/drivers/tests/test_saj-emanager.py @@ -2,9 +2,13 @@ The register sheet is the community workbook on stanus74/home-assistant-saj-h2-modbus discussion 182. Nothing here was read -off a live controller. The numbers below are the sheet's own scales applied -to a fixture, including the two sign assumptions (battery discharge-positive, -grid import-positive) and high-word-first int32. +off a live controller. + +Site signs, same as the other drivers: generation is negative, battery +charge and grid import are positive, battery discharge and grid export are +negative. The H2 wire is the other way round on the battery only, so the +fixture's battery word is discharge-positive and the assertions are the +site watts. 32-bit values are high word first. """ from __future__ import annotations @@ -228,9 +232,9 @@ def run_lua(body: str) -> dict[str, str]: print("FAULT " .. tostring(host._faulted)) print("COUNT " .. metric("saj_emanager_device_count")) print("DIVISOR " .. metric("saj_emanager_phase_scale_divisor")) -print("LOAD " .. metric("saj_emanager_load_w")) -print("INV " .. metric("saj_emanager_inv_w")) -print("GEN " .. metric("saj_emanager_gen_w")) +print("LOAD " .. metric("saj_emanager_load_w_vendor")) +print("INV " .. metric("saj_emanager_inv_w_vendor")) +print("GEN " .. metric("saj_emanager_gen_w_vendor")) print("CAP " .. metric("saj_emanager_battery_capacity_raw")) print("FEED " .. metric("saj_emanager_vendor_feed_in_wh")) print("SELL " .. metric("saj_emanager_vendor_sell_wh")) @@ -285,20 +289,22 @@ def test_three_inverter_plant_in_site_convention(): assert out["SLOT_COUNT"] == str(DEVICE_COUNT * 12) assert out["PV_EMITS"] == "1" + # Generation is negative, same as the other drivers. assert float(out["PV_W"]) == -PV_W + assert float(out["PV_W"]) < 0 assert float(out["PV_WH"]) == PV_ENERGY_RAW * 10 - # Vendor discharge-positive charging (-6000) flips to site +6000. + # Wire -6000 is charging on a discharge-positive register. Site charge is +. assert out["BAT_EMITS"] == "1" - assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert float(out["BAT_W"]) == 6000 assert math.isclose(float(out["BAT_SOC"]), SOC_RAW / 10000, rel_tol=1e-6) assert float(out["BAT_CHG"]) == BAT_CHARGE_RAW * 10 assert float(out["BAT_DIS"]) == BAT_DISCHARGE_RAW * 10 assert out["CAP"] == str(CAP_RAW) - # Vendor import-positive export stays negative. Not divided by 3. + # Wire -2500 is export on an import-positive register. Site export is −. assert out["MET_EMITS"] == "1" - assert float(out["MET_W"]) == GRID_W_VENDOR + assert float(out["MET_W"]) == -2500 assert math.isclose(float(out["MET_HZ"]), L1_HZ_RAW / 100, rel_tol=1e-6) assert math.isclose(float(out["MET_L1V"]), L1_V_RAW / 10, rel_tol=1e-6) assert math.isclose(float(out["MET_L1A"]), L1_A_RAW / 10, rel_tol=1e-6) @@ -319,6 +325,40 @@ def test_three_inverter_plant_in_site_convention(): assert out["GEN"] == "nil" +def _i32_words(value: int) -> tuple[int, int]: + hi, lo = _i32_regs(value) + return hi, lo + + +def test_site_signs_both_directions(): + """Sourceful: charge/import positive, discharge/export/generation negative. + + Same boundary as drivers/lua/saj.lua. The H2 wire is discharge-positive + on the battery and import-positive on the grid; this driver flips only + the battery. + """ + charging = poll_once() + assert float(charging["BAT_W"]) == 6000 + assert float(charging["MET_W"]) == -2500 + assert float(charging["MET_L1W"]) == -800 + assert float(charging["PV_W"]) == -PV_W + + discharge_hi, discharge_lo = _i32_words(4500) # wire: discharging + import_hi, import_lo = _i32_words(1800) # wire: importing + phase_hi, phase_lo = _i32_words(400) # wire: L1 importing + out = poll_once( + f"host._modbus_registers.holding[{REG_SUMMARY}][8] = {discharge_hi}\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][9] = {discharge_lo}\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][13] = {import_hi}\n" + f"host._modbus_registers.holding[{REG_SUMMARY}][14] = {import_lo}\n" + f"host._modbus_registers.holding[{REG_PHASES}][4] = {phase_hi}\n" + f"host._modbus_registers.holding[{REG_PHASES}][5] = {phase_lo}") + assert float(out["BAT_W"]) == -4500 + assert float(out["MET_W"]) == 1800 + assert float(out["MET_L1W"]) == 400 + assert float(out["PV_W"]) < 0 + + def test_summed_voltage_and_frequency_are_divided_power_is_not(): """Eniris: 3 inverters can report 3×230 V and 3×50 Hz. Watts stay summed.""" out = poll_once(summed=True) @@ -368,7 +408,7 @@ def test_battery_from_power_alone(): """A non-zero battery watt is a pack, even when SoC and capacity read 0.""" out = poll_once(pack="power") assert out["BAT_EMITS"] == "1" - assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert float(out["BAT_W"]) == 6000 assert float(out["BAT_SOC"]) == 0.0 @@ -376,7 +416,7 @@ def test_soc_sentinel_is_omitted(): out = poll_once(pack="soc_sentinel") assert out["BAT_EMITS"] == "1" assert out["BAT_SOC"] == "nil" - assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert float(out["BAT_W"]) == 6000 def test_device_zero_is_emitted_a_failed_read_is_not(): @@ -435,7 +475,7 @@ def test_energy_failure_omits_counters(): f'host._modbus_read_fail_addresses[{REG_ENERGY}] = "Illegal Data Address"') assert float(out["PV_W"]) == -PV_W assert out["PV_WH"] == "nil" - assert float(out["BAT_W"]) == -BAT_W_VENDOR + assert float(out["BAT_W"]) == 6000 assert out["BAT_CHG"] == "nil" assert out["BAT_DIS"] == "nil" assert out["FEED"] == "nil" diff --git a/index.yaml b/index.yaml index e22dda5..5bdfa73 100644 --- a/index.yaml +++ b/index.yaml @@ -536,14 +536,14 @@ drivers: size_bytes: 10858 sha256: "38749fb157e0929c26c31ccb9e8013e3ee81f584349be1dd7cc60792b9e3adeb" - name: "saj-emanager" - version: "0.1.0" + version: "0.1.1" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: false - size_bytes: 24558 - sha256: "643df6850d158b005ca52e8a579295a18c26916fb1ab01c185adc91407e33a05" + size_bytes: 25082 + sha256: "4efc62ef56d5736e24895929d478a78788586796bb2d45be64acdd5051df640f" - name: "saj" version: "1.2.0" tier: community diff --git a/manifests/saj-emanager.yaml b/manifests/saj-emanager.yaml index 938fdfb..d9b5030 100644 --- a/manifests/saj-emanager.yaml +++ b/manifests/saj-emanager.yaml @@ -1,5 +1,5 @@ name: "saj-emanager" -version: "0.1.0" +version: "0.1.1" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -28,11 +28,11 @@ upstream_docs: kind: manual url_stability: stable min_host_version: "2.0.0" -size_bytes: 24558 +size_bytes: 25082 dkb_id: "" -sha256: "643df6850d158b005ca52e8a579295a18c26916fb1ab01c185adc91407e33a05" +sha256: "4efc62ef56d5736e24895929d478a78788586796bb2d45be64acdd5051df640f" signature: "" bytecode_sha256: "" bytecode_signature: "" bytecode_size: 0 -changelog: "Read-only aggregate telemetry for the eManager C1 Pro over Modbus TCP. No control writes. Not tested on hardware." +changelog: "Site signs match the catalog: PV negative, battery positive when charging, grid positive on import. Still read-only and untested on hardware." diff --git a/support-status.json b/support-status.json index 078fb2f..228c835 100644 --- a/support-status.json +++ b/support-status.json @@ -1220,7 +1220,7 @@ }, { "catalog_source": true, - "catalog_version": "0.1.0", + "catalog_version": "0.1.1", "driver_id": "saj-emanager", "targets": { "blixt-l1": {