diff --git a/CHANGELOG.md b/CHANGELOG.md index dca8b25..285bed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## pixii 2.1.5 + +Treat unsupported SunSpec status values as unknown. An unknown or missing +charge status no longer clears a known calibration fault; a recognized +non-testing status must confirm recovery. Log changes to unknown charge +status while keeping battery and meter telemetry available. Unknown status +at startup does not by itself mark the battery as calibrating. + ## vag_vehicle 0.1.1 A dataset of more than a few kB failed on a box with "registry overflow". FTW runs gopher-lua, whose `table.concat` puts every item of the range on a value stack of about 5,000 slots, and the unzip joined its whole output in one call. It now joins at most 256 items per call. The test harness refuses longer `table.concat` ranges too, since the C Lua the tests run has no such limit. The dataset cap drops from 4 MiB to 2 MiB: in FTW's host, 1 MB of JSON took 0.6 s to unzip and read on an Apple M-series core, a poll has 10 seconds, and a Raspberry Pi is several times slower. diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 9cb249c..dd3fc4e 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -112,8 +112,8 @@ Catalog source is not proof that a target can install or run a driver. | p1_hdlc | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | p1_meter | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | p1_meter | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | -| pixii | 2.1.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| pixii | 2.1.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | pixii_pv | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | | pixii_pv | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | | saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index c7a171f..83f0291 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1032,7 +1032,7 @@ manufacturers: protocols: - protocol: modbus driver: "pixii" - version: "2.1.4" + version: "2.1.5" ders: [battery, meter] control: false firmware_versions: "" diff --git a/drivers/lua/pixii.lua b/drivers/lua/pixii.lua index 568ff88..a16949b 100644 --- a/drivers/lua/pixii.lua +++ b/drivers/lua/pixii.lua @@ -29,7 +29,7 @@ DRIVER = { id = "pixii", name = "Pixii PowerShaper", manufacturer = "Pixii", - version = "2.1.4", + version = "2.1.5", protocols = { "modbus" }, capabilities = { "battery", "meter" }, description = "Pixii PowerShaper commercial battery storage via Modbus TCP.", @@ -155,9 +155,10 @@ local function config_bool(config, key) return v == true or v == 1 or v == "1" or v == "true" or v == "yes" or v == "on" end -local function read_u16(addr) +-- SunSpec enum16/uint16 uses 0xffff for an unsupported value. +local function read_status_u16(addr) local regs = probe_read(addr, 1, "holding") - if regs then return regs[1] end + if regs and regs[1] ~= 0xffff then return regs[1] end return nil end @@ -207,11 +208,13 @@ local function label_for(labels, value) return labels[value] or ("unknown_" .. tostring(value)) end +local last_charge_label = nil + local function read_battery_status() - local charge_status = read_u16(REG_BATTERY_CHARGE_STATUS) - local control_mode = read_u16(REG_BATTERY_CONTROL_MODE) - local battery_state = read_u16(REG_BATTERY_STATE) - local vendor_state = read_u16(REG_BATTERY_STATE_VENDOR) + local charge_status = read_status_u16(REG_BATTERY_CHARGE_STATUS) + local control_mode = read_status_u16(REG_BATTERY_CONTROL_MODE) + local battery_state = read_status_u16(REG_BATTERY_STATE) + local vendor_state = read_status_u16(REG_BATTERY_STATE_VENDOR) local event1 = read_u32_be(REG_BATTERY_EVT1) if charge_status ~= nil then host.emit_metric("battery_charge_status_code", charge_status) end @@ -221,6 +224,12 @@ local function read_battery_status() if event1 ~= nil then host.emit_metric("battery_event1_bits", event1) end local charge_label = label_for(charge_status_labels, charge_status) + local charge_status_known = charge_status ~= nil and charge_status_labels[charge_status] ~= nil + if not charge_status_known and charge_label ~= last_charge_label then + host.log("warn", "Pixii: charge status " .. charge_label + .. "; calibration state is unknown; retaining any previous calibration fault") + end + last_charge_label = charge_label local control_label = label_for(control_mode_labels, control_mode) local state_label = label_for(battery_state_labels, battery_state) local key = charge_label .. "/" .. control_label .. "/" .. state_label .. "/" .. tostring(vendor_state) .. "/" .. tostring(event1) @@ -238,8 +247,11 @@ local function read_battery_status() -- SunSpec 802 ChaSt=testing (7): Pixii is calibrating and ignores -- external setpoints. Flag a device fault so dispatch + MPC exclude it - -- while keeping telemetry and site-meter data live. - if charge_status ~= nil then + -- while keeping telemetry and site-meter data live. Only a recognized + -- status can clear it: absent, unsupported or unknown values do not + -- prove calibration has finished. Unknown status at startup is not + -- evidence of calibration either, so it does not invent a fault. + if charge_status_known then local calibrating = charge_status == 7 host.set_device_fault(calibrating, calibrating and "Pixii battery calibrating/testing (SunSpec ChaSt=testing)" or "") diff --git a/drivers/tests/test_pixii_status.py b/drivers/tests/test_pixii_status.py new file mode 100644 index 0000000..9a319e3 --- /dev/null +++ b/drivers/tests/test_pixii_status.py @@ -0,0 +1,95 @@ +"""Missing SunSpec status cannot prove that battery calibration finished.""" + +from pathlib import Path +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +LUA = ROOT / "lua55" +pytestmark = pytest.mark.skipif(not LUA.exists(), reason="run make test-driver ID=pixii") + + +def run_lua(body: str) -> None: + script = ''' +package.path = "drivers/tests/lua_harness/?.lua;" .. package.path +require("host_mock") +host.reset() +host._modbus_registers.holding[40000] = {0x5375, 0x6e53} +host._modbus_registers.holding[40132] = 50 +host._modbus_registers.holding[40138] = 0 +host._modbus_registers.holding[40143] = 3 +dofile("drivers/lua/pixii.lua") +driver_init({}) +local function poll(code) + host._modbus_registers.holding[40137] = code + host._metrics = {} + driver_poll() + return host._emitted.battery[#host._emitted.battery] +end +''' + body + result = subprocess.run([str(LUA), "-e", script], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_pixii_unsupported_status_is_unknown_and_warns_once() -> None: + run_lua(''' +host._modbus_registers.holding[40138] = 0xffff +host._modbus_registers.holding[40143] = 0xffff +host._modbus_registers.holding[40144] = 0xffff +for i = 1, 10 do + local battery = poll(0xffff) + assert(battery.charge_status == "unknown", battery.charge_status) + assert(battery.control_mode == "unknown", battery.control_mode) + assert(battery.battery_state == "unknown", battery.battery_state) + assert(battery.battery_vendor_state == nil) + for _, name in ipairs({"battery_charge_status_code", "battery_control_mode_code", + "battery_state_code", "battery_vendor_state_code"}) do + assert(host._metrics[name] == nil, name .. " emitted an unsupported value") + end + assert(not host._faulted, "unknown status invented a calibration fault") +end +local warnings = 0 +for _, message in ipairs(host._logs) do + if message:find("calibration state is unknown", 1, true) then warnings = warnings + 1 end +end +assert(warnings == 1, "expected one unknown-status warning, got " .. warnings) +assert(#host._emitted.meter == 10, "unknown status stopped meter telemetry") +''') + + +@pytest.mark.parametrize("unknown", ["0xffff", "99", "0"]) +def test_pixii_unknown_status_does_not_clear_calibration(unknown: str) -> None: + run_lua(f''' +poll(7) +assert(host._faulted, "TESTING did not block dispatch") +poll({unknown}) +assert(host._faulted, "unknown status cleared calibration") +assert(host._fault_reason:find("calibrating", 1, true)) +poll(4) +assert(not host._faulted, "valid charge status did not clear calibration") +''') + + +def test_pixii_failed_status_read_does_not_clear_calibration() -> None: + run_lua(''' +poll(7) +host._modbus_read_fail_addresses[40137] = "timeout" +local battery = poll(4) +assert(host._faulted, "failed status read cleared calibration") +assert(battery.charge_status == "unknown") +host._modbus_read_fail_addresses[40137] = nil +poll(3) +assert(not host._faulted, "valid status did not recover after a transient read failure") +''') + + +@pytest.mark.parametrize("status", [1, 2, 3, 4, 5, 6]) +def test_pixii_known_status_can_clear_calibration(status: int) -> None: + run_lua(f''' +poll(7) +poll({status}) +assert(not host._faulted) +assert(host._metrics.battery_charge_status_code.value == {status}) +assert(host._metrics.battery_control_mode_code.value == 0, "remote control code 0 is valid") +''') diff --git a/index.yaml b/index.yaml index 16d629e..0635f6f 100644 --- a/index.yaml +++ b/index.yaml @@ -498,14 +498,14 @@ drivers: size_bytes: 19344 sha256: "29f2bd2f73c0d39d809839ca60b40c3e4281f1dda267ba2b26e66bd9ab80c27b" - name: "pixii" - version: "2.1.4" + version: "2.1.5" tier: core protocol: modbus connectivity: local ders: [battery, meter] control: false - size_bytes: 27846 - sha256: "2b9c691f7171794e39dd28e927a9d09ac706e507cd646b8f1ca2bf76c7d892ea" + size_bytes: 28604 + sha256: "d6e6dea5bb1924d34116768e01f843a50e13ecc5214704e2bd90e8025d05677e" - name: "pixii_pv" version: "0.3.2" tier: core diff --git a/manifests/pixii.yaml b/manifests/pixii.yaml index bcd82e5..b1b39cd 100644 --- a/manifests/pixii.yaml +++ b/manifests/pixii.yaml @@ -1,5 +1,5 @@ name: "pixii" -version: "2.1.4" +version: "2.1.5" tier: core author: "Sourceful Labs AB and community contributors" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "" min_driver_version: "1.1.0" min_host_version: "2.0.0" -size_bytes: 27846 +size_bytes: 28604 dkb_id: "pixii" -sha256: "2b9c691f7171794e39dd28e927a9d09ac706e507cd646b8f1ca2bf76c7d892ea" +sha256: "d6e6dea5bb1924d34116768e01f843a50e13ecc5214704e2bd90e8025d05677e" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 0a04364..50a1c95 100644 --- a/support-status.json +++ b/support-status.json @@ -1136,7 +1136,7 @@ }, { "catalog_source": true, - "catalog_version": "2.1.4", + "catalog_version": "2.1.5", "driver_id": "pixii", "targets": { "blixt-l1": {