diff --git a/CHANGELOG.md b/CHANGELOG.md index b1affa6..9d21e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ ## [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 +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. +- 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. + +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..5339321 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.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 7936061..31e9414 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.1" + 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..f6d9f20 --- /dev/null +++ b/drivers/lua/saj-emanager.lua @@ -0,0 +1,607 @@ +-- 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 ═════════════════════════════════════════════════════════════════ +-- +-- 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 +-- +-- 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. +-- +-- 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 ═════ +-- +-- 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.1", + 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 + +-- 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 + +-- 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 +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] + + -- 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_vendor", inv_w) end + local load_w = i32_at(regs, IDX_LOAD) + 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_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 = {} + 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..a8f79fb --- /dev/null +++ b/drivers/tests/test_saj-emanager.py @@ -0,0 +1,581 @@ +"""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. + +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 + +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_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")) +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" + # 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 + + # Wire -6000 is charging on a discharge-positive register. Site charge is +. + assert out["BAT_EMITS"] == "1" + 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) + + # Wire -2500 is export on an import-positive register. Site export is −. + assert out["MET_EMITS"] == "1" + 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) + 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 _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) + + 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"]) == 6000 + 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"]) == 6000 + + +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"]) == 6000 + 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..5bdfa73 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.1" + tier: community + protocol: modbus + connectivity: local + ders: [pv, battery, meter] + control: false + 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 new file mode 100644 index 0000000..d9b5030 --- /dev/null +++ b/manifests/saj-emanager.yaml @@ -0,0 +1,38 @@ +name: "saj-emanager" +version: "0.1.1" +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: 25082 +dkb_id: "" +sha256: "4efc62ef56d5736e24895929d478a78788586796bb2d45be64acdd5051df640f" +signature: "" +bytecode_sha256: "" +bytecode_signature: "" +bytecode_size: 0 +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 403c1de..228c835 100644 --- a/support-status.json +++ b/support-status.json @@ -1218,6 +1218,27 @@ } } }, + { + "catalog_source": true, + "catalog_version": "0.1.1", + "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",