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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
66 changes: 25 additions & 41 deletions drivers/lua/saj-emanager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 ═════
--
Expand Down Expand Up @@ -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 ══════════════════════════════
--
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -420,16 +419,25 @@ 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

-- 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.
----------------------------------------------------------------------------
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -603,5 +586,6 @@ end

function driver_cleanup()
announced = false
scale_logged = false
read_failures = {}
end
49 changes: 15 additions & 34 deletions drivers/tests/test_saj-emanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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]:
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand All @@ -493,15 +472,15 @@ 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"


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"


Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions manifests/saj-emanager.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: "saj-emanager"
version: "0.1.1"
version: "0.1.2"
tier: community
author: "Sourceful Labs AB"
protocol: modbus
Expand Down Expand Up @@ -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."
Loading
Loading