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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions SUPPORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion devices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
30 changes: 21 additions & 9 deletions drivers/lua/pixii.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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
Comment on lines +158 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the status specifications in upstream_docs

This new sentinel and enum interpretation is explicitly decoded from the SunSpec Device Information Model and model 802, but manifests/pixii.yaml still has no upstream_docs entry. As a result, the weekly watcher cannot alert maintainers if either specification changes or disappears; add the durable source URLs already identified for this change to the manifest.

AGENTS.md reference: AGENTS.md:L76-L82

Useful? React with 👍 / 👎.

return nil
end

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 "")
Expand Down
95 changes: 95 additions & 0 deletions drivers/tests/test_pixii_status.py
Original file line number Diff line number Diff line change
@@ -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")
''')
6 changes: 3 additions & 3 deletions index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions manifests/pixii.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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: ""
Expand Down
2 changes: 1 addition & 1 deletion support-status.json
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,7 @@
},
{
"catalog_source": true,
"catalog_version": "2.1.4",
"catalog_version": "2.1.5",
"driver_id": "pixii",
"targets": {
"blixt-l1": {
Expand Down
Loading