From 5533b1062fddf29620f973f8850f9cf3e3f96270 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 15:33:18 +0000 Subject: [PATCH] fix(pixii): recover after the device was unreachable (2.1.9) A poll where every read fails no longer counts toward retiring a register. The next poll that gets an answer clears the failure counts, and a register that stays silent while the device is up is tried again every five minutes. Register 40288 is still left alone between retries. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- CHANGELOG.md | 17 ++ SUPPORT_STATUS.md | 4 +- devices.yaml | 2 +- drivers/lua/pixii.lua | 120 ++++++++++-- .../tests/test_absent_optional_registers.py | 6 +- drivers/tests/test_pixii_unreachable.py | 177 ++++++++++++++++++ index.yaml | 6 +- manifests/pixii.yaml | 6 +- support-status.json | 2 +- 9 files changed, 318 insertions(+), 22 deletions(-) create mode 100644 drivers/tests/test_pixii_unreachable.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fcb12..eefd99c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## pixii 2.1.9 + +A power cut left the driver offline until someone restarted it. Each register +keeps its own failure count and stops being read after three misses. When the +Pixii boots slower than FTW, every read fails for about fifteen seconds, +three polls at five seconds, and every register is retired. The device then +answers and the driver never asks again. + +Misses count on a poll where some other register answered. A poll where every +read fails leaves the counts unchanged: the device is unreachable. The next +poll that gets an answer clears the counts. Register 40288 on PowerShaper +firmware below CPU 2.0.23 still stops after three misses while the device is +answering other registers, and is tried again every five minutes. + +FTW's bundled copy does not move with this commit. Bump the pin and run +`scripts/sync-bundled-drivers.sh` (srcfl/ftw#1620). + ## sungrow 1.6.0 Read the live values once a second. `driver_poll` returns 1000 ms. Each poll diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 0b0bdf8..edccf31 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.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | -| pixii | 2.1.8 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.9 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.9 | 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 | | remne_p1ib | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 5bd6a3f..10d347d 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1032,7 +1032,7 @@ manufacturers: protocols: - protocol: modbus driver: "pixii" - version: "2.1.8" + version: "2.1.9" ders: [battery, meter] control: false firmware_versions: "" diff --git a/drivers/lua/pixii.lua b/drivers/lua/pixii.lua index ea1931f..d2e8f8d 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.8", + version = "2.1.9", protocols = { "modbus" }, capabilities = { "battery", "meter" }, description = "Pixii PowerShaper commercial battery storage via Modbus TCP.", @@ -97,35 +97,130 @@ end -- poll, and the stale-telemetry watchdog takes the driver offline. The site -- then reports nothing at all, which is worse than reporting one field less. -- PowerShaper firmware below CPU 2.0.23 answers Modbus exception 2 on 40288 --- (meter_energy_sf), which is how this was found in the field. +-- (meter_energy_sf) while the rest of the device is up, which is how this +-- was found in the field. -- --- Three attempts absorb a transient blip; after that we stop asking. A --- restart re-probes, so firmware that gains the register is picked up. +-- Three misses while other registers answer, and that register is left +-- alone. A poll where every read fails is a different event: the device is +-- unreachable (still booting after a power cut, or the link is down). Those +-- misses leave the counts unchanged. Counting them retired the whole map in +-- about fifteen seconds — three polls at five seconds — and the driver then +-- stayed dark until a restart, because the device came up to a map that was +-- no longer being read. The next poll that gets any answer clears the +-- counts, so a slow boot comes back on its own. +-- +-- A register that really is missing is probed again every five minutes, the +-- short end of a five-to-ten minute retry. That single miss is how a +-- firmware update is picked up; it replaces needing a restart, and it does +-- not hold every poll in a failed state. -- -- This replaces a scale-factor-only cache that gave up after one failure. -- Every read goes through here now: the scale factors were the fourteen -- registers that fix covered, and the value reads below are the twenty-three -- it left behind. local GIVE_UP_AFTER = 3 +local GIVE_UP_RETRY_MIN = 5 +local GIVE_UP_RETRY_MS = GIVE_UP_RETRY_MIN * 60 * 1000 local read_failures = {} +local given_up_since = {} + +-- Set for the body of driver_poll. Misses land in poll_failed and are +-- committed from finish_poll_reads, so a poll whose every read failed can +-- be dropped before it retires anything. +local polling = false +local poll_reads = 0 +local poll_ok = 0 +local poll_failed = {} +local poll_now = 0 +local unreachable_streak = false + +local function clock_ms() + if polling then return poll_now end + if type(host.millis) == "function" then return host.millis() end + return 0 +end + +local function note_failure(addr) + local failures = (read_failures[addr] or 0) + 1 + if failures > GIVE_UP_AFTER then failures = GIVE_UP_AFTER end + read_failures[addr] = failures + if failures < GIVE_UP_AFTER then return end + local already = given_up_since[addr] ~= nil + given_up_since[addr] = clock_ms() + if not already then + host.log("info", string.format( + "Pixii: register %d did not answer %d times while the device " .. + "was up; leaving it alone for %d minutes", + addr, GIVE_UP_AFTER, GIVE_UP_RETRY_MIN)) + end +end local function probe_read(addr, count, kind) - if (read_failures[addr] or 0) >= GIVE_UP_AFTER then return nil end + local failures = read_failures[addr] or 0 + if failures >= GIVE_UP_AFTER then + local since = given_up_since[addr] + if since ~= nil and (clock_ms() - since) < GIVE_UP_RETRY_MS then + return nil + end + end + if polling then poll_reads = poll_reads + 1 end local ok, regs = pcall(host.modbus_read, addr, count, kind) if ok and regs and regs[1] ~= nil then + if given_up_since[addr] ~= nil then + host.log("info", string.format( + "Pixii: register %d answered again", addr)) + end read_failures[addr] = nil + given_up_since[addr] = nil + if polling then + poll_ok = poll_ok + 1 + poll_failed[addr] = nil + end 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( - "Pixii: register %d did not answer %d times; leaving it alone " .. - "until restart", addr, GIVE_UP_AFTER)) + if polling then + poll_failed[addr] = true + else + note_failure(addr) end return nil end +local function begin_poll_reads() + poll_reads = 0 + poll_ok = 0 + poll_failed = {} + -- One sample for the whole poll. Sampling first keeps clock_ms() on + -- this instant while polling is set. + poll_now = 0 + if type(host.millis) == "function" then poll_now = host.millis() end + polling = true +end + +local function finish_poll_reads() + if poll_reads > 0 and poll_ok == 0 then + if not unreachable_streak then + host.log("info", + "Pixii: every register read failed; the device looks " .. + "unreachable, so register failure counts are unchanged") + end + unreachable_streak = true + elseif poll_ok > 0 then + if unreachable_streak then + host.log("info", + "Pixii: the device answered after an unreachable stretch; " .. + "register failure counts are cleared") + read_failures = {} + given_up_since = {} + unreachable_streak = false + end + for addr, _ in pairs(poll_failed) do + note_failure(addr) + end + end + polling = false +end + -- Read a single i16-typed scale factor register. SF=0 (x1 scaling) is the -- right fallback when the register is absent, and matches units where it -- exists but reads zero. @@ -359,6 +454,8 @@ function driver_poll() host.log("warn", "Pixii: heartbeat write failed: " .. tostring(hb_err)) end + begin_poll_reads() + -- Read serial number once from SunSpec Common Model (offset 52 from -- the common block → absolute 40052, 16 regs ASCII). if not sn_read then @@ -638,6 +735,7 @@ function driver_poll() if meter_var_ok then host.emit_metric("meter_var", meter_var) end host.emit_metric("grid_hz", meter_hz) + finish_poll_reads() return 5000 end diff --git a/drivers/tests/test_absent_optional_registers.py b/drivers/tests/test_absent_optional_registers.py index dbf918e..d92acce 100644 --- a/drivers/tests/test_absent_optional_registers.py +++ b/drivers/tests/test_absent_optional_registers.py @@ -90,7 +90,11 @@ def poll_with_absent(path: Path, absent: int, present: int, def test_pixii_gives_up_on_an_absent_scale_factor(): - """40288 meter_energy_sf is absent on PowerShaper firmware below CPU 2.0.23.""" + """40288 meter_energy_sf is absent on PowerShaper firmware below CPU 2.0.23. + + The clock stays put. Pixii retries a given-up register after five minutes; + these six polls are inside that window, so a read here means it never stopped. + """ out = poll_with_absent(ROOT / "drivers" / "lua" / "pixii.lua", absent=40288, present=40256, config="{host = '127.0.0.1', port = 502, unit_id = 1}") diff --git a/drivers/tests/test_pixii_unreachable.py b/drivers/tests/test_pixii_unreachable.py new file mode 100644 index 0000000..5120a6b --- /dev/null +++ b/drivers/tests/test_pixii_unreachable.py @@ -0,0 +1,177 @@ +"""An unreachable Pixii must not retire every register. + +Each register used to keep its own failure count and stop after three misses, +for the life of the process. That is the right behaviour for register 40288, +which old PowerShaper firmware never answers while the rest of the device is +up. It is the wrong behaviour when the whole device is down: three polls at +five seconds, about fifteen seconds, and every register is retired. A Pixii +that boots slower than FTW then answers into a driver that has stopped +asking, and telemetry stays dark until a restart. + +Misses count only when some other read in that poll succeeded. A poll of +nothing but failures leaves the counts alone. After that stretch, the next +poll that gets an answer clears them. A register that stays silent while the +device is up is still left alone, and is tried again five minutes later. +""" + +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") + +RETRY_MS = 5 * 60 * 1000 + + +def run_lua(body: str) -> None: + script = ''' +package.path = "drivers/tests/lua_harness/?.lua;" .. package.path +require("host_mock") +host.reset() + +local function reads_of(addr, from_index) + local n = 0 + for i = from_index or 1, #host._calls do + local call = host._calls[i] + if call.func == "modbus_read" and call.args[1] == addr then + n = n + 1 + end + end + return n +end + +local function last(kind) + local emitted = host._emitted[kind] + assert(emitted and #emitted > 0, "no " .. kind .. " telemetry") + return emitted[#emitted] +end + +local function at(ms) + host._millis_counter = ms + return driver_poll() +end + +dofile("drivers/lua/pixii.lua") +''' + 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_recovers_when_the_device_boots_after_the_driver() -> None: + """The device is unreachable from init through more than three polls, then answers.""" + run_lua(''' +host._modbus_registers.holding[40168] = 2500 +host._modbus_registers.holding[40132] = 80 +host._modbus_registers.holding[40252] = 4200 +host._modbus_read_error = "connection refused" +driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) + +local from = #host._calls + 1 +for _ = 1, 6 do + local interval = driver_poll() + assert(interval == 5000, "poll interval " .. tostring(interval)) +end +assert(reads_of(40168, from) == 6, + "stopped reading battery power while the device was down, reads=" .. reads_of(40168, from)) +assert(reads_of(40252, from) == 6, + "stopped reading meter power while the device was down, reads=" .. reads_of(40252, from)) + +host._modbus_read_error = nil +from = #host._calls + 1 +local interval = driver_poll() +assert(interval == 5000, "poll interval " .. tostring(interval)) +assert(reads_of(40168, from) == 1, "battery power was not read once the device was up") +assert(reads_of(40252, from) == 1, "meter power was not read once the device was up") +local battery = last("battery") +local meter = last("meter") +assert(battery.w == 2500, "battery.w " .. tostring(battery.w)) +assert(battery.soc == 0.8, "battery.soc " .. tostring(battery.soc)) +assert(meter.w == 4200, "meter.w " .. tostring(meter.w)) +''') + + +def test_pixii_outage_does_not_add_to_an_existing_failure_count() -> None: + """Two real misses, then a stretch where nothing answers, must not retire the register.""" + run_lua(''' +host._modbus_registers.holding[40168] = 2500 +host._modbus_read_fail_addresses[40168] = "timeout" +driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) +driver_poll() +driver_poll() +assert(reads_of(40168) == 2, "battery power reads before the outage " .. reads_of(40168)) + +host._modbus_read_fail_addresses[40168] = nil +host._modbus_read_error = "connection refused" +local from = #host._calls + 1 +for _ = 1, 4 do driver_poll() end +assert(reads_of(40168, from) == 4, + "outage retired battery power, reads during the outage=" .. reads_of(40168, from)) + +host._modbus_read_error = nil +from = #host._calls + 1 +driver_poll() +assert(reads_of(40168, from) == 1, "battery power was not read after the outage") +local battery = last("battery") +assert(battery.w == 2500, "battery.w " .. tostring(battery.w)) +''') + + +def test_pixii_retries_an_absent_register_after_five_minutes() -> None: + """40288 stays quiet while the device is up, then gets one probe at five minutes.""" + run_lua(f''' +host._millis_step = 0 +host._modbus_read_fail_addresses[40288] = "Illegal Data Address" +driver_init({{host = "127.0.0.1", port = 502, unit_id = 1}}) +at(1000) +at(2000) +at(3000) +assert(reads_of(40288) == 3, "40288 reads before give-up " .. reads_of(40288)) + +local from = #host._calls + 1 +at(3000 + {RETRY_MS} - 1) +assert(reads_of(40288, from) == 0, "40288 was read before five minutes") + +from = #host._calls + 1 +at(3000 + {RETRY_MS}) +assert(reads_of(40288, from) == 1, "40288 was not probed at five minutes") + +from = #host._calls + 1 +at(3000 + {RETRY_MS}) +assert(reads_of(40288, from) == 0, "a failed retry was read again on the next poll") +assert(reads_of(40256, from) == 1, "a present scale factor stopped being read") +assert(last("battery") ~= nil) +assert(last("meter") ~= nil) +''') + + +def test_pixii_outage_clears_give_up_without_waiting_five_minutes() -> None: + """A register retired while the device was up is probed again after an unreachable stretch.""" + run_lua(''' +host._millis_step = 0 +host._modbus_registers.holding[40252] = 4200 +host._modbus_read_fail_addresses[40288] = "Illegal Data Address" +driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) +at(1000) +at(2000) +at(3000) +assert(reads_of(40288) == 3, "40288 reads before give-up " .. reads_of(40288)) + +local from = #host._calls + 1 +at(8000) +assert(reads_of(40288, from) == 0, "40288 was read after it was given up") + +host._modbus_read_error = "connection refused" +at(10000) +at(15000) +host._modbus_read_error = nil + +from = #host._calls + 1 +for i = 1, 5 do at(20000 + i * 5000) end +local n = reads_of(40288, from) +assert(n == 3, "40288 reads after the outage " .. n) +assert(last("meter").w == 4200, "meter.w " .. tostring(last("meter").w)) +''') diff --git a/index.yaml b/index.yaml index 4e4ba0f..6890625 100644 --- a/index.yaml +++ b/index.yaml @@ -498,14 +498,14 @@ drivers: size_bytes: 19344 sha256: "29f2bd2f73c0d39d809839ca60b40c3e4281f1dda267ba2b26e66bd9ab80c27b" - name: "pixii" - version: "2.1.8" + version: "2.1.9" tier: core protocol: modbus connectivity: local ders: [battery, meter] control: false - size_bytes: 29119 - sha256: "5fc8e600eac7a70020e01e32c531f350f49bd076cac8a5598c2c06205b05f6b8" + size_bytes: 32519 + sha256: "77e233e2d24e970ebff8eaacd67967f57b651677d25ce7b57a1f97c3eaeb71fe" - name: "pixii_pv" version: "0.3.2" tier: core diff --git a/manifests/pixii.yaml b/manifests/pixii.yaml index 19e2a96..c8a2088 100644 --- a/manifests/pixii.yaml +++ b/manifests/pixii.yaml @@ -1,5 +1,5 @@ name: "pixii" -version: "2.1.8" +version: "2.1.9" tier: core author: "Sourceful Labs AB and community contributors" protocol: modbus @@ -24,9 +24,9 @@ upstream_docs: title: "SunSpec model 802: battery charge and control states" kind: register_map url_stability: stable -size_bytes: 29119 +size_bytes: 32519 dkb_id: "pixii" -sha256: "5fc8e600eac7a70020e01e32c531f350f49bd076cac8a5598c2c06205b05f6b8" +sha256: "77e233e2d24e970ebff8eaacd67967f57b651677d25ce7b57a1f97c3eaeb71fe" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 67eef26..43c3a0f 100644 --- a/support-status.json +++ b/support-status.json @@ -1136,7 +1136,7 @@ }, { "catalog_source": true, - "catalog_version": "2.1.8", + "catalog_version": "2.1.9", "driver_id": "pixii", "targets": { "blixt-l1": {