diff --git a/CHANGELOG.md b/CHANGELOG.md index eefd99c..b950ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### pixii_pv 0.3.3 + +Report zero PV generation when the measured phase sum is negative. Negating +that sum used to emit positive PV power, which FTW rejects, so fresh readings +could leave PV stale or missing after sunset. Sum the phases before limiting +generation to zero; keep their signed diagnostic values. Missing, invalid or +non-finite phase power emits no PV reading. Idle polls do not refresh PV. + ## pixii 2.1.9 A power cut left the driver offline until someone restarted it. Each register diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index edccf31..a9ebd61 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -114,8 +114,8 @@ Catalog source is not proof that a target can install or run a driver. | p1_meter | 2.0.2 | 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 | +| pixii_pv | 0.3.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 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 | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index 10d347d..f14fbca 100644 --- a/devices.yaml +++ b/devices.yaml @@ -1021,7 +1021,7 @@ manufacturers: protocols: - protocol: mqtt driver: "pixii_pv" - version: "0.3.2" + version: "0.3.3" ders: [pv, meter] control: false firmware_versions: "" diff --git a/drivers/lua/pixii_pv.lua b/drivers/lua/pixii_pv.lua index 2b50c6b..11d576d 100644 --- a/drivers/lua/pixii_pv.lua +++ b/drivers/lua/pixii_pv.lua @@ -14,7 +14,7 @@ DRIVER = { id = "pixii_pv", name = "Pixii PowerShaper (PV + meter)", manufacturer = "Pixii", - version = "0.3.2", + version = "0.3.3", protocols = { "mqtt" }, capabilities = { "pv", "meter" }, read_only = true, @@ -108,29 +108,33 @@ function driver_poll() -------------------------------------------------------------------------- -- PV (external-CT meter on the PV feed). - -- Pixii reports PV as positive generation; site convention requires - -- negative (power leaving the grid boundary into the loads), so negate. + -- Pixii reports generation as positive. A negative net reading means + -- no generation, so report zero PV and keep signed phase diagnostics. -------------------------------------------------------------------------- if pv_payload then - local w1 = tonumber(pv_payload.meter_w1) or 0 - local w2 = tonumber(pv_payload.meter_w2) or 0 - local w3 = tonumber(pv_payload.meter_w3) or 0 - local pv = {} - pv.w = -(w1 + w2 + w3) - local kwh_imp = tonumber(pv_payload.meter_kwh_imp) - if kwh_imp then pv.generation_wh = kwh_imp * 1000.0 end - host.emit("pv", pv) - - host.emit_metric("pv_w_total", pv.w) - host.emit_metric("pv_l1_w", -w1) - host.emit_metric("pv_l2_w", -w2) - host.emit_metric("pv_l3_w", -w3) - if pv_payload.meter_v1 then host.emit_metric("pv_l1_v", tonumber(pv_payload.meter_v1)) end - if pv_payload.meter_v2 then host.emit_metric("pv_l2_v", tonumber(pv_payload.meter_v2)) end - if pv_payload.meter_v3 then host.emit_metric("pv_l3_v", tonumber(pv_payload.meter_v3)) end - if pv_payload.meter_a1 then host.emit_metric("pv_l1_a", tonumber(pv_payload.meter_a1)) end - if pv_payload.meter_a2 then host.emit_metric("pv_l2_a", tonumber(pv_payload.meter_a2)) end - if pv_payload.meter_a3 then host.emit_metric("pv_l3_a", tonumber(pv_payload.meter_a3)) end + local w1 = tonumber(pv_payload.meter_w1) + local w2 = tonumber(pv_payload.meter_w2) + local w3 = tonumber(pv_payload.meter_w3) + local total = w1 and w2 and w3 and (w1 + w2 + w3) + -- Check before clamping: missing or non-finite power is not zero. + if total and total == total and total ~= math.huge and total ~= -math.huge then + local pv = {} + pv.w = -math.max(0, total) + local kwh_imp = tonumber(pv_payload.meter_kwh_imp) + if kwh_imp then pv.generation_wh = kwh_imp * 1000.0 end + host.emit("pv", pv) + + host.emit_metric("pv_w_total", pv.w) + host.emit_metric("pv_l1_w", -w1) + host.emit_metric("pv_l2_w", -w2) + host.emit_metric("pv_l3_w", -w3) + if pv_payload.meter_v1 then host.emit_metric("pv_l1_v", tonumber(pv_payload.meter_v1)) end + if pv_payload.meter_v2 then host.emit_metric("pv_l2_v", tonumber(pv_payload.meter_v2)) end + if pv_payload.meter_v3 then host.emit_metric("pv_l3_v", tonumber(pv_payload.meter_v3)) end + if pv_payload.meter_a1 then host.emit_metric("pv_l1_a", tonumber(pv_payload.meter_a1)) end + if pv_payload.meter_a2 then host.emit_metric("pv_l2_a", tonumber(pv_payload.meter_a2)) end + if pv_payload.meter_a3 then host.emit_metric("pv_l3_a", tonumber(pv_payload.meter_a3)) end + end end -------------------------------------------------------------------------- diff --git a/drivers/tests/test_pixii_pv.py b/drivers/tests/test_pixii_pv.py new file mode 100644 index 0000000..d05965f --- /dev/null +++ b/drivers/tests/test_pixii_pv.py @@ -0,0 +1,104 @@ +"""Pixii's PV feed stays valid when the measured total falls below zero.""" + +import json +import subprocess +from pathlib import Path + +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_pv") + + +def run_lua(body: str) -> None: + script = ''' +package.path = "drivers/tests/lua_harness/?.lua;" .. package.path +require("host_mock") +host.reset() +dofile("drivers/lua/pixii_pv.lua") +driver_init({}) + +local function poll(payload) + host._mqtt_buffer = {{ + topic = "pixii/status/test-pixii/meter_ext6", + payload = payload, + }} + assert(driver_poll() == 1000) +end +''' + body + result = subprocess.run( + [str(LUA), "-e", script], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("phases", "expected"), + [ + ((1500, 1000, 500), -3000), + ((0, 0, 0), 0), + ((-1, -2, -3), 0), + ((50, -3, -2), -45), + ((-50, 3, 2), 0), + ((5, -3, -2), 0), + ], +) +def test_pixii_pv_reports_net_generation(phases, expected) -> None: + payload = json.dumps(dict( + meter_source="PV", meter_w1=phases[0], meter_w2=phases[1], + meter_w3=phases[2], meter_kwh_imp=12.5)) + run_lua(f''' +poll({json.dumps(payload)}) +local pv = host._emitted.pv +assert(pv and #pv == 1, "expected one PV reading") +assert(pv[1].w == {expected}, "PV W = " .. tostring(pv[1].w)) +assert(pv[1].w <= 0, "FTW rejects positive PV power") +assert(pv[1].generation_wh == 12500, "generation counter changed") +assert(host._metrics.pv_w_total.value == {expected}, "total metric differs") +assert(host._metrics.pv_l1_w.value == {-phases[0]}, "phase 1 changed") +assert(host._metrics.pv_l2_w.value == {-phases[1]}, "phase 2 changed") +assert(host._metrics.pv_l3_w.value == {-phases[2]}, "phase 3 changed") +''') + + +@pytest.mark.parametrize("phase", ("meter_w1", "meter_w2", "meter_w3")) +@pytest.mark.parametrize("bad_value", (None, "invalid", "1e400", "-1e400")) +def test_pixii_pv_does_not_turn_invalid_power_into_zero(phase, bad_value) -> None: + payload = dict(meter_source="PV", meter_w1=-1, meter_w2=-2, meter_w3=-3) + if bad_value is None: + del payload[phase] + else: + payload[phase] = bad_value + encoded = json.dumps(json.dumps(payload)) + run_lua(f''' +poll({encoded}) +assert(not host._emitted.pv, "invalid phase power emitted as PV") +assert(not host._metrics.pv_w_total, "invalid phase power emitted as total") +''') + + +def test_pixii_pv_sunset_idle_and_morning() -> None: + run_lua(''' +poll('{"meter_source":"PV","meter_w1":100,"meter_w2":100,"meter_w3":100}') +for _ = 1, 5 do + poll('{"meter_source":"PV","meter_w1":-1,"meter_w2":-2,"meter_w3":-3}') +end +assert(#host._emitted.pv == 6, "night readings stopped") +for i = 2, 6 do + assert(host._emitted.pv[i].w == 0, "night reading must show no generation") +end +host._emitted = {} +driver_poll() +assert(not host._emitted.pv, "idle poll replayed old PV data") + +host._mqtt_buffer = {{ + topic = "pixii/status/test-pixii/meter", + payload = '{"meter_w":-750}', +}} +driver_poll() +assert(host._emitted.meter[1].w == -750, "grid export sign changed") +assert(not host._emitted.pv, "grid-only message refreshed PV") +poll('{"meter_source":"PV","meter_w1":"1000","meter_w2":"2000","meter_w3":"3000"}') +assert(host._emitted.pv[1].w == -6000, "morning production did not return") +''') diff --git a/index.yaml b/index.yaml index 6890625..0817bb3 100644 --- a/index.yaml +++ b/index.yaml @@ -507,14 +507,14 @@ drivers: size_bytes: 32519 sha256: "77e233e2d24e970ebff8eaacd67967f57b651677d25ce7b57a1f97c3eaeb71fe" - name: "pixii_pv" - version: "0.3.2" + version: "0.3.3" tier: core protocol: mqtt connectivity: local ders: [pv, meter] control: false - size_bytes: 6699 - sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" + size_bytes: 6980 + sha256: "6b1a7c67e99a7b056d9a209800fc8ce3e63f47796846644eccaf8b31da3b988e" - name: "remne_p1ib" version: "0.1.0" tier: community diff --git a/manifests/pixii_pv.yaml b/manifests/pixii_pv.yaml index 2c26a15..35808f2 100644 --- a/manifests/pixii_pv.yaml +++ b/manifests/pixii_pv.yaml @@ -1,5 +1,5 @@ name: "pixii_pv" -version: "0.3.2" +version: "0.3.3" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -15,9 +15,9 @@ tested_devices: notes: "Read-only Pixii telemetry: PV (external CTs) + grid meter (meter_w). Battery / SoC comes from the Modbus driver, not this one." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 6699 +size_bytes: 6980 dkb_id: "pixii_pv" -sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" +sha256: "6b1a7c67e99a7b056d9a209800fc8ce3e63f47796846644eccaf8b31da3b988e" signature: "" bytecode_sha256: "" diff --git a/support-status.json b/support-status.json index 43c3a0f..0a93501 100644 --- a/support-status.json +++ b/support-status.json @@ -1157,7 +1157,7 @@ }, { "catalog_source": true, - "catalog_version": "0.3.2", + "catalog_version": "0.3.3", "driver_id": "pixii_pv", "targets": { "blixt-l1": {