From 470c454de6f56e134643c983084f7064b6b8f84f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Nov 2026 11:48:59 +0000 Subject: [PATCH] fix(saj-emanager): use the saj brand key NovaCore rejects set_make("SAJ") and does not provide emit_metric. Device count and the phase divisor are logged once instead. Signed-off-by: Cursor Agent Co-authored-by: David Mozart --- CHANGELOG.md | 12 ++++++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/saj-emanager.lua | 66 +++++++++++------------------- drivers/tests/test_saj-emanager.py | 49 +++++++--------------- index.yaml | 6 +-- manifests/saj-emanager.yaml | 8 ++-- support-status.json | 2 +- 8 files changed, 63 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d21e65..352b581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### saj-emanager 0.1.2 + +The NovaCore device-support registry accepts this driver. + +- `host.set_make` is the brand key `saj`. `SAJ` is not a key in `/brands`. + Same correction the SAJ H2 driver took in 1.0.2. +- `host.emit_metric` is not a host function in that registry. Device count + and the phase-scale divisor are one log line each. Inverter, load, + generator, raw capacity, and the FeedIn/Sell counters are not published: + the first three have no stated sign, capacity is not a known Wh unit, and + FeedIn versus Sell is still an open direction question. + ### saj-emanager 0.1.1 Emitted power uses the same signs as the other drivers. Generation is diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 5339321..4fec4ef 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| saj-emanager | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| saj-emanager | 0.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| saj-emanager | 0.1.2 | 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 31e9414..2701aa6 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1104,7 +1104,7 @@ manufacturers: protocols: - protocol: modbus driver: "saj-emanager" - version: "0.1.1" + version: "0.1.2" ders: [pv, battery, meter] control: false firmware_versions: "" diff --git a/drivers/lua/saj-emanager.lua b/drivers/lua/saj-emanager.lua index f6d9f20..8233329 100644 --- a/drivers/lua/saj-emanager.lua +++ b/drivers/lua/saj-emanager.lua @@ -79,9 +79,9 @@ -- 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. +-- Load (0x8493), inverter (0x848D) and generator (0x8498) are int32 with +-- no stated sign. They are left unpublished: a DER field would invent a +-- sign, and the NovaCore registry host does not provide emit_metric. -- -- ═══ Voltage and frequency may be summed — the sheet does not say so ═════ -- @@ -113,15 +113,15 @@ -- 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. +-- The capacity unit on the sheet is "K", not kWh and not Wh. A non-zero +-- word means a pack is present. It is not converted and not published. -- -- ═══ 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. +-- are not published: SAJ uses both words for both directions in different +-- PDFs, so neither is meter import or export. -- -- ═══ Control 0x8400-0x8405 — NOT IMPLEMENTED ══════════════════════════════ -- @@ -164,7 +164,7 @@ DRIVER = { id = "saj-emanager", name = "SAJ eManager C1 Pro", manufacturer = "SAJ", - version = "0.1.1", + version = "0.1.2", host_api_min = 1, host_api_max = 1, protocols = { "modbus" }, @@ -222,8 +222,6 @@ 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 @@ -333,6 +331,7 @@ end local GIVE_UP_AFTER = 3 local read_failures = {} local announced = false +local scale_logged = false local function complete(regs, count) if regs == nil then return false end @@ -420,9 +419,6 @@ 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( @@ -430,6 +426,18 @@ local function note_identity(device_count, devices) device_count, sn or "(none)")) end +-- One line, once. The registry host has no emit_metric, and the voltage +-- check at Castellum needs to see whether the sum was divided. +local function note_phase_scale(divided, device_count, phases_ok) + if scale_logged or not phases_ok then return end + if device_count == nil or device_count < 1 then return end + scale_logged = true + local divisor = 1 + if divided then divisor = device_count end + host.log("info", string.format( + "SAJ eManager: phase voltage/frequency divisor %d", divisor)) +end + ---------------------------------------------------------------------------- -- Summary block → DER fields. Nil means the word did not decode. ---------------------------------------------------------------------------- @@ -445,7 +453,6 @@ 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 @@ -463,16 +470,6 @@ 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_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 @@ -487,12 +484,6 @@ local function decode_summary(regs) 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 @@ -534,11 +525,6 @@ local function apply_energy(pv, battery, regs) 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) @@ -550,7 +536,8 @@ end ---------------------------------------------------------------------------- function driver_init(config) - host.set_make("SAJ") + -- NovaCore brand key from /brands. "SAJ" is rejected. + host.set_make("saj") host.set_model("eManager C1 Pro") end @@ -569,11 +556,7 @@ function driver_poll() 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 + note_phase_scale(divided, device_count, phases ~= nil) local energy = probe_read(REG_ENERGY, ENERGY_WORDS, "holding") if energy then @@ -603,5 +586,6 @@ end function driver_cleanup() announced = false + scale_logged = false read_failures = {} end diff --git a/drivers/tests/test_saj-emanager.py b/drivers/tests/test_saj-emanager.py index a8f79fb..d047e74 100644 --- a/drivers/tests/test_saj-emanager.py +++ b/drivers/tests/test_saj-emanager.py @@ -197,11 +197,6 @@ def run_lua(body: str) -> dict[str, str]: 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"]) @@ -230,14 +225,7 @@ def run_lua(body: str) -> dict[str, str]: 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")) +print("LOGS " .. table.concat(host._logs, " || ")) 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 @@ -275,10 +263,10 @@ def test_three_inverter_plant_in_site_convention(): out = poll_once() assert out["INTERVAL"] == "5000" - assert out["MAKE"] == "SAJ" + assert out["MAKE"] == "saj" assert out["MODEL"] == "eManager C1 Pro" assert out["SN"] == SERIALS[0] - assert out["COUNT"] == "3" + assert "DeviceNum=3" in out["LOGS"] assert out["RATED"] == str(RATED_W) assert out["FAULT"] == "false" assert out["BAD_KIND"] == "0" @@ -300,7 +288,6 @@ def test_three_inverter_plant_in_site_convention(): 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" @@ -313,16 +300,11 @@ def test_three_inverter_plant_in_site_convention(): 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. + # Sheet scale was already one phase, so the logged divisor stays 1. + assert "divisor 1" in out["LOGS"] + # FeedIn / Sell are not import and export. Direction is unconfirmed. 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]: @@ -366,7 +348,7 @@ def test_summed_voltage_and_frequency_are_divided_power_is_not(): 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" + assert "divisor 3" in out["LOGS"] # 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 @@ -379,15 +361,15 @@ def test_implausible_voltage_that_does_not_divide_cleanly_is_left_alone(): 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" + assert "divisor 1" in out["LOGS"] 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 "DeviceNum=" not in out["LOGS"] + assert "divisor" not in out["LOGS"] assert out["SN"] == "nil" assert out["SLOT_READS"] == "0" # 150.00 Hz stays 150.00 Hz. Inventing a divisor of 3 would hide that. @@ -398,7 +380,6 @@ def test_sum_is_not_guessed_when_device_count_did_not_answer(): 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 @@ -459,7 +440,7 @@ def test_short_phase_block_does_not_zero_fill(): assert out["MET_L1V"] == "nil" assert out["MET_L1W"] == "nil" assert out["MET_HZ"] == "nil" - assert out["DIVISOR"] == "nil" + assert "divisor" not in out["LOGS"] def test_phase_failure_keeps_the_totals(): @@ -478,8 +459,6 @@ def test_energy_failure_omits_counters(): 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(): @@ -493,7 +472,7 @@ def test_int32_sentinel_is_not_a_power(): def test_zero_devices_does_not_scan_slots(): out = poll_once(count=0) - assert out["COUNT"] == "0" + assert "DeviceNum=0" in out["LOGS"] assert out["SLOT_READS"] == "0" assert out["SN"] == "nil" assert out["PV_EMITS"] == "1" @@ -501,7 +480,7 @@ def test_zero_devices_does_not_scan_slots(): def test_sentinel_device_count_does_not_scan_slots(): out = poll_once(count=0xFFFF) - assert out["COUNT"] == "nil" + assert "DeviceNum=" not in out["LOGS"] assert out["SLOT_READS"] == "0" @@ -569,6 +548,8 @@ def test_source_is_tcp_only_and_does_not_read_or_write_control(): code = _code_without_comments() assert "RTU is not supported" in text assert re.search(r"port\s*=\s*502", text) + assert 'set_make("saj")' in code + assert "host.emit_metric" not in code # 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) diff --git a/index.yaml b/index.yaml index 5bdfa73..ebee215 100644 --- a/index.yaml +++ b/index.yaml @@ -536,14 +536,14 @@ drivers: size_bytes: 10858 sha256: "38749fb157e0929c26c31ccb9e8013e3ee81f584349be1dd7cc60792b9e3adeb" - name: "saj-emanager" - version: "0.1.1" + version: "0.1.2" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: false - size_bytes: 25082 - sha256: "4efc62ef56d5736e24895929d478a78788586796bb2d45be64acdd5051df640f" + size_bytes: 24245 + sha256: "db7692001df4fcd19130c1a7e5b0f61f425f519bf2ba92be4ae1371c345da792" - name: "saj" version: "1.2.0" tier: community diff --git a/manifests/saj-emanager.yaml b/manifests/saj-emanager.yaml index d9b5030..0348b11 100644 --- a/manifests/saj-emanager.yaml +++ b/manifests/saj-emanager.yaml @@ -1,5 +1,5 @@ name: "saj-emanager" -version: "0.1.1" +version: "0.1.2" 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: 25082 +size_bytes: 24245 dkb_id: "" -sha256: "4efc62ef56d5736e24895929d478a78788586796bb2d45be64acdd5051df640f" +sha256: "db7692001df4fcd19130c1a7e5b0f61f425f519bf2ba92be4ae1371c345da792" 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." +changelog: "Brand key saj. Drop emit_metric; the NovaCore registry does not provide it. Still read-only and untested on hardware." diff --git a/support-status.json b/support-status.json index 228c835..fd82e8f 100644 --- a/support-status.json +++ b/support-status.json @@ -1220,7 +1220,7 @@ }, { "catalog_source": true, - "catalog_version": "0.1.1", + "catalog_version": "0.1.2", "driver_id": "saj-emanager", "targets": { "blixt-l1": {