From 6d4f676517fbd62d92b043a284176a7c1197efc5 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:43:48 -0700 Subject: [PATCH 1/8] test: read the reference tree recaptured with ebus-panel-sim 0.9.0 span-panel-api 3.8.0b2 / schema-1 1.4.0b4 recaptured the schema-1 reference tree with ebus-panel-sim 0.9.0. The BESS meter is now published discharge-positive, opposite power-flows/battery (r202639's frame), and each charger declares its user charge-current limit (format 6:32) without publishing a value. Seven tests read the old tree; the integration reads the new one correctly, so the tests move. BESS telemetry: the capture-reading sign tests are scoped to r202639's frame, with the premise pinned (meter opposite the flow). The old frame is built by republishing the capture under an r202633 version string with the meter in the flow's sign, and keeps its own charging-reads- negative, two-sensors-agree and republish tests. EVSE charge limit: tests about a valued limit publish one, inside the ceiling, the way a charger does once a user sets it. Added coverage of the capture's own state (no user limit: the number shows the ceiling, bounded by it, available), of a limit set and then cleared through the transport, and of the pending write and the catalogued spelling with and without a user limit. Replay: captures_replay.replay sends a tree's empty value as Homie 5's 0x00 rather than a zero-length payload, which the library now reads as a clear, and the charge-limit tests' adapter replays through it. --- tests/captures_replay.py | 18 ++- tests/test_bess_telemetry.py | 229 ++++++++++++++++++++++++-------- tests/test_evse_charge_limit.py | 202 +++++++++++++++++++++------- tests/test_sensor_entities.py | 23 ++++ 4 files changed, 361 insertions(+), 111 deletions(-) diff --git a/tests/captures_replay.py b/tests/captures_replay.py index 689c52ea..b2c2f5c7 100644 --- a/tests/captures_replay.py +++ b/tests/captures_replay.py @@ -25,6 +25,7 @@ from typing import Final from ebus_panel_sim import Tree, load_reference_capture +from ebus_sdk import encode_empty_string from span_panel_api import ControlInterceptor, LeafNameMismatch, SpanPanelSnapshot from span_panel_api.adapters import resolve_adapter from span_panel_api.dispatch import select_adapter_key @@ -177,12 +178,27 @@ def without_topics(tree: RetainedTree, device_id: str, *topics: str) -> Retained } +def retained_payload(value: str) -> str: + """The payload a tree's value is replayed as: `""` as `0x00`, anything else as itself. + + A tree holds each value as the device published it, decoded, so `""` is an + empty value. On the wire a zero-length payload is not one: it deletes the + retained message, and the library reads it as a clear, the property + unpublished. Homie 5 spells an empty value as a single `0x00`, which the + library reads back as `""`, so that is what the broker would replay. + """ + return encode_empty_string(value) + + def replay(tree: RetainedTree) -> SchemaAdapter: """Deliver the tree to the adapter the library dispatches to, panel first. The schema the adapter is built with stands in for `GET /api/v2/homie/schema` and carries only what dispatch reads from it, the data-model version and the firmware; neither parent/child adapter reads its `types`. + + Every value goes through `retained_payload`, so a tree holding an empty value + replays it as one rather than as a clear. """ panel = panel_device_id(tree) data_model_version = tree[panel].get("info/data-model-version") @@ -203,7 +219,7 @@ def replay(tree: RetainedTree) -> SchemaAdapter: adapter.handle_message(f"{prefix}/$state", topics["$state"]) for topic, payload in topics.items(): if not topic.startswith("$"): - adapter.handle_message(f"{prefix}/{topic}", payload) + adapter.handle_message(f"{prefix}/{topic}", retained_payload(payload)) assert adapter.is_ready(), f"the replay of {panel} never became ready" return adapter diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py index 2983ab1a..06a115ab 100644 --- a/tests/test_bess_telemetry.py +++ b/tests/test_bess_telemetry.py @@ -11,24 +11,26 @@ republishing it, deleting it, or dropping the node that carries it. **The sign is the hard part, and it is what most of this module is about.** The -capture is of a *charging* battery, published as a positive `meter/active-power` -because the enclosure meters the BESS the way it meters a circuit it feeds, in a -frame where positive means power flowing *into* the thing being metered. Each -path applies exactly one negation — the snapshot negates the BESS's own meter, -and `battery_power` negates the enclosure's `power-flows/battery` — so both -sensors land on the same convention in the UI, reading negative for this charging -battery. A sensor whose sign contradicted the one beside it would be worse than -no sensor, so the agreement is asserted directly rather than inferred from the -two definitions. - -**The agreement is structural; the direction is inherited from the fixture.** -The capture is in the frame firmware through r202633 publishes: both wire -properties carry the *same* sign as each other and each path applies exactly one -negation, so the two entities cannot disagree whatever the convention. The tests -that read the capture unmodified are scoped to that frame; r202639, which -publishes the BESS meter as the flow's negation, has its own section at the end -of the sign tests. That is what the agreement assertions below pin, and it holds -independently of which way is charging. Neither negation changed here: +capture is of a *charging* battery, and the BESS's own `meter/active-power` has +been published in two frames. Firmware through r202633 publishes it +charge-positive, the way the enclosure meters a circuit it feeds, positive when +power flows *into* the thing being metered; from r202639 it is discharge-positive, +the negation of `power-flows/battery`. The library brings either into the +snapshot discharge-positive, negating the old frame and passing the new one +through, and `battery_power` negates the enclosure's `power-flows/battery`, which +kept its sign across the change. So both sensors land on the same convention in +the UI, reading negative for this charging battery. A sensor whose sign +contradicted the one beside it would be worse than no sensor, so the agreement is +asserted directly rather than inferred from the definitions. + +**The agreement is asserted in both frames; the direction is inherited from the +fixture.** The capture is in r202639's frame: `span-panel-api` recaptured the +reference tree with `ebus-panel-sim` 0.9.0, which publishes the BESS meter as the +flow's negation. The tests that read the capture unmodified are scoped to that +frame. The old frame is no longer in any capture the library ships, so the tests +about it republish the capture the way r202633 publishes it: a version string the +library reads as r202633, and the meter in the old frame (`_in_the_old_frame`), +named as such. Neither negation the integration owns changed: `BATTERY_POWER_SENSOR` is the single negation it has been since 2.0.8, and what a real panel displays depends on what that panel publishes, not on this fixture. @@ -46,6 +48,7 @@ from __future__ import annotations +from collections.abc import Callable from datetime import date, datetime from decimal import Decimal import json @@ -93,6 +96,23 @@ COMMS_KEY = "communication_state" ENCLOSURE_FLOW_KEY = BATTERY_POWER_SENSOR.key +R202633_FIRMWARE = "spanos2/r202633/01" +"""A release that publishes the BESS meter charge-positive, the old frame.""" + +R202639_FIRMWARE = "spanos2/r202639/03" +"""The first release that publishes the BESS meter discharge-positive, the capture's frame.""" + + +def _in_the_old_frame(raw: float) -> float: + """A BESS meter reading as a release before r202639 publishes the same instant. + + The capture is in r202639's frame, which negated the BESS meter and left + `power-flows/battery` alone, so the old frame's reading of the same battery + is the negation. Named rather than written as a bare minus sign so a test + that builds the old frame says that it does. + """ + return -raw + @pytest.fixture(autouse=True) def _mock_entity_registry() -> Any: @@ -246,19 +266,27 @@ def test_the_capture_is_a_charging_battery() -> None: assert flows["battery"] > 0 -def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None: - """The two properties describing this battery must not disagree on the wire. +def test_the_capture_publishes_the_bess_meter_opposite_the_enclosure_flow() -> None: + """The frame every test reading the capture unmodified is scoped to. - `battery_power` negates the enclosure's flow and `bess_meter_power` negates - the BESS's own meter; that is only coherent because the two are published in - the same frame in this r202633-frame capture. Pinned here rather than - assumed, because "negate exactly one of them" is the wrong rule for r202639, - which publishes them opposed (covered below). + r202639 publishes the BESS meter as the negation of `power-flows/battery`, + and the capture carries no version string the library reads a release + from, so the opposed signs are what select that frame: the meter passes + through and only `battery_power` negates. Pinned here rather than assumed, + because a capture retaken in the old frame would turn the capture-reading + sign tests into old-frame tests without any of them failing for that + reason. + + The old frame built from the capture is checked against the same flow, + because the tests about that frame are only about it if the built meter + carries the flow's sign, which is what r202633 publishes. """ bess_meter = float(_published(BESS, POWER_TOPIC)) enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) - assert (bess_meter < 0) == (enclosure_flow < 0) + assert bess_meter != 0 and enclosure_flow != 0 + assert (bess_meter > 0) != (enclosure_flow > 0) + assert (_in_the_old_frame(bess_meter) > 0) == (enclosure_flow > 0) # --------------------------------------------------------------------------- @@ -269,19 +297,46 @@ def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None: def test_charging_reads_negative() -> None: """The convention, asserted on the state a user sees rather than on the field. - The wire is charge-positive — the enclosure meters the BESS the way it meters - anything else it feeds, positive into the device — and the snapshot negates - it, so a charging battery reads negative and the sensor is discharge-positive. - The library pins the same relation on the same capture - (`test_schema_one_devices.py`: `battery.power_w == -raw`, and `< 0` here). + From r202639 the wire is discharge-positive, so the capture publishes this + charging battery negative and the snapshot passes it through: the sensor is + discharge-positive and a charging battery reads negative. The library pins + the same relation on the same capture in `test_schema_one_devices.py`: + `test_the_captured_tree_resolves_to_the_new_frame_through_the_snapshot` + (`battery.power_w == raw`, and `< 0`). - Magnitude and sign are asserted separately on purpose: losing the negation - keeps the magnitude, so only the sign check catches it. + The value and the sign are asserted separately on purpose: the value pins + that nothing negates a meter already in the sensor's frame, the sign pins + the convention a user reads whichever frame the wire was in. """ published = float(_published(BESS, POWER_TOPIC)) state = _state(schema_one_snapshot(), POWER_KEY) - assert state == -published + assert state == published + assert isinstance(state, float) and state < 0 + + +def test_charging_reads_negative_in_the_old_frame() -> None: + """The same charging battery, published the way firmware through r202633 does. + + That wire is charge-positive, the enclosure metering the BESS the way it + meters anything else it feeds, positive into the device, and the snapshot + negates it, so the sensor reads negative for the same battery it reads + negative from the capture. The library pins the same relation in + `test_schema_one_devices.py`: + `test_an_r202633_tree_reports_the_same_charging_battery_through_the_snapshot`. + + Magnitude and sign are asserted separately on purpose: losing the negation + keeps the magnitude, so only the sign check catches it. + """ + old_frame = _in_the_old_frame(float(_published(BESS, POWER_TOPIC))) + tree = schema_one_tree() + tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = R202633_FIRMWARE + tree[BESS][POWER_TOPIC] = str(old_frame) + + state = _state(schema_one_snapshot(tree), POWER_KEY) + + assert old_frame > 0 + assert state == -old_frame assert isinstance(state, float) and state < 0 @@ -290,10 +345,11 @@ def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None: `battery_power` reads the enclosure's arbitrated `power-flows/battery`; this one reads the BESS's own meter. The specification defines the first as the - negation of the second, and the r202633-frame capture publishes them with the - *same* sign instead (r202639 conforms; see the end of the sign tests). Each - path negates exactly once here, so the UI shows one convention either way, and - the check is on the states rather than on either definition. A flip on either side fails here even if the side that flipped + negation of the second, which is how the capture publishes them (r202639's + frame); the old frame, which publishes them with the *same* sign, has its own + section below. Exactly one path negates in each frame, so the UI shows one + convention either way, and the check is on the states rather than on either + definition. A flip on either side fails here even if the side that flipped still looks self-consistent. """ snapshot = schema_one_snapshot() @@ -309,8 +365,8 @@ def test_they_agree_when_the_battery_discharges_too() -> None: """Agreement at one operating point could be coincidence; this is the other. Both properties are republished with the battery discharging — the capture's - two values negated — and both sensors must go positive together, the sensors - being discharge-positive. + two values negated, so still opposed, still the capture's frame — and both + sensors must go positive together, the sensors being discharge-positive. """ snapshot = _republishing_both( power=-float(_published(BESS, POWER_TOPIC)), @@ -333,19 +389,58 @@ def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnaps # --------------------------------------------------------------------------- -# Firmware r202639: the BESS meter is published as the flow's negation +# Firmware through r202633: the BESS meter is published in the flow's sign # --------------------------------------------------------------------------- -R202639_FIRMWARE = "spanos2/r202639/03" + +def _r202633(*, enclosure_flow: float) -> SpanPanelSnapshot: + """Republish the capture the way firmware through r202633 publishes it. + + Before r202639 the BESS meter is charge-positive, equal to + `power-flows/battery` rather than its negation, and the enclosure reports a + version string the library reads that release from. + """ + tree = schema_one_tree() + tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = R202633_FIRMWARE + tree[SCHEMA_ONE_PANEL][ENCLOSURE_FLOW_TOPIC] = str(enclosure_flow) + tree[BESS][POWER_TOPIC] = str(enclosure_flow) + return schema_one_snapshot(tree) + + +@pytest.mark.parametrize("direction", ["charging", "discharging"]) +def test_r202633_the_two_sensors_agree(direction: str) -> None: + """In the old frame the wire properties share a sign, and the sensors still agree. + + The agreement the capture itself pinned until it was retaken in r202639's + frame: here the library negates the BESS meter and `battery_power` negates + the flow, so each path negates exactly once. The capture's enclosure flow is + a charging battery; negating it gives the discharging case. + """ + captured = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)) + enclosure_flow = captured if direction == "charging" else -captured + snapshot = _r202633(enclosure_flow=enclosure_flow) + + own_meter = _state(snapshot, POWER_KEY) + flow_sensor = _state(snapshot, ENCLOSURE_FLOW_KEY) + + assert isinstance(own_meter, float) and isinstance(flow_sensor, float) + assert own_meter == flow_sensor + assert (own_meter > 0) == (direction == "discharging") + + +# --------------------------------------------------------------------------- +# Firmware r202639: the BESS meter is published as the flow's negation +# --------------------------------------------------------------------------- def _r202639(*, enclosure_flow: float, power: float | None = None) -> SpanPanelSnapshot: - """Re-publish the capture the way r202639 firmware publishes it. + """Republish the capture under an r202639 version string. From r202639 the BESS meter is the negation of `power-flows/battery` rather - than equal to it, and the enclosure reports a version string the library can - read the release from. `power` overrides the meter for the one test that - needs the two properties to disagree. + than equal to it, which is the frame the capture already publishes; what this + adds is a version string the library reads the release from, so the frame no + longer rests on the two signs. `power` overrides the meter for the one test + that needs the two properties to disagree. """ tree = schema_one_tree() tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = R202639_FIRMWARE @@ -392,25 +487,41 @@ def test_r202639_the_firmware_version_decides_when_the_flow_is_idle() -> None: # --------------------------------------------------------------------------- -def test_republishing_the_meter_moves_the_sensor() -> None: - """The mutation proof. The republished value differs in magnitude and in sign - from what the capture carries, so a sensor pinned to a constant — or wired to - the enclosure's flow instead — cannot report it. - - The panel is pinned to an r202633 firmware version. The capture's own version - string does not parse, and without one the library reads a BESS meter whose - sign opposes the enclosure's flow as r202639's frame, which is what this - one-sided republish would otherwise look like.""" +@pytest.mark.parametrize( + ("firmware", "on_the_wire"), + [ + (R202639_FIRMWARE, lambda discharging: discharging), + (R202633_FIRMWARE, _in_the_old_frame), + ], + ids=["r202639", "r202633"], +) +def test_republishing_the_meter_moves_the_sensor( + firmware: str, on_the_wire: Callable[[float], float] +) -> None: + """The mutation proof, in each frame. The battery is republished discharging + at half the captured rate, so the state differs in magnitude and in sign from + what the capture reads, and a sensor pinned to a constant — or wired to the + enclosure's flow instead — cannot report it. + + The panel is pinned to each release's version string. The capture's own does + not parse, and without one the library picks the frame from the meter's sign + against the flow's, which a one-sided republish that flips the meter flips + too. + + The capture's meter is already discharge-positive, which is the sensor's + frame, so half its negation is the discharging battery as the sensor reads + it; `on_the_wire` is how each release publishes that.""" published = float(_published(BESS, POWER_TOPIC)) discharging = -published / 2 tree = schema_one_tree() - tree[BESS][POWER_TOPIC] = str(discharging) - tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = "spanos2/r202633/01" + tree[BESS][POWER_TOPIC] = str(on_the_wire(discharging)) + tree[SCHEMA_ONE_PANEL]["info/firmware-version"] = firmware snapshot = schema_one_snapshot(tree) - assert _state(snapshot, POWER_KEY) == -discharging - assert _state(snapshot, POWER_KEY) != -published + assert discharging > 0 + assert _state(snapshot, POWER_KEY) == discharging + assert _state(snapshot, POWER_KEY) != published def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None: diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index b6d38629..484a3e70 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -5,10 +5,18 @@ The tests are shaped around that. **Nothing here names an amperage.** Every expectation is computed from the -capture, and where the capture publishes the same value on both chargers — it -publishes 32 on each — the test republishes differing values first, because an +capture, and where the capture publishes the same value on both chargers — the +same ceiling on each — the test republishes differing values first, because an assertion satisfied by reading one charger twice proves nothing about two. +**The capture sets no user limit.** From firmware r202639 a charger publishes +its user limit only once a user sets one, and `ebus-panel-sim` 0.9.0, which the +reference tree was captured with, publishes that behaviour: each charger +declares the limit settable and publishes no value for it. The control then +shows the commissioned ceiling, the limit the charger is enforcing. A test about +a valued limit publishes one (`_user_limit`) the way a charger would after a +user set it. + **The write is proved to the wire, not to a mock.** `test_the_write_reaches_the _wire_as_one_publish` drives the entity through the real client and the real schema_1 adapter and asserts the exact topic and payload the transport hands the @@ -36,6 +44,7 @@ import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry from span_panel_api import SpanPanelSnapshot +from span_panel_api.protocol import SchemaAdapter from custom_components.span_panel import PLATFORMS, SpanPanelRuntimeData from custom_components.span_panel.const import DOMAIN @@ -58,6 +67,7 @@ schema_one_snapshot, schema_one_tree, ) +from .captures_replay import HOMIE_PREFIX, replay from .factories import pv_binding_for EVSE = "evse" @@ -86,6 +96,15 @@ def _published(tree: dict[str, dict[str, str]], device_id: str, topic: str) -> i return int(value) +def _user_limit(tree: dict[str, dict[str, str]], device_id: str) -> int: + """A limit a user would set on this charger: inside its ceiling, and not equal to it. + + Below the ceiling so it cannot be mistaken for the ceiling standing in for + an absent limit, which is what the control shows when none is set. + """ + return _published(tree, device_id, CEILING_TOPIC) - 8 + + def _declaration(tree: dict[str, dict[str, str]], device_id: str, property_id: str) -> dict[str, object]: """One property's declaration out of the charger's own `$description`.""" description = json.loads(tree[device_id]["$description"]) @@ -126,14 +145,16 @@ def drop(description: dict[str, dict[str, dict[str, dict[str, dict[str, object]] return schema_one_snapshot(_mutated_description(schema_one_tree(), device_id, drop)) -def _renamed_to_catalog(device_id: str) -> dict[str, dict[str, str]]: - """The capture with one charger publishing the eBus `charge-limit` spelling. +def _renamed_to_catalog(tree: dict[str, dict[str, str]], device_id: str) -> dict[str, dict[str, str]]: + """`tree` with one charger publishing the eBus `charge-limit` spelling, in place. The naming the catalog specifies and no producer we have publishes. The entity must not be able to tell: the library resolves the node from the - `$description`, so the rename is a rename and nothing else. + `$description`, so the rename is a rename and nothing else. The limit's + value moves only where the charger publishes one: unset, it is unpublished + under either spelling. The ceiling is always published, so its absence is a + fixture fault and fails here. """ - tree = schema_one_tree() description = json.loads(tree[device_id]["$description"]) properties = description["nodes"].pop(CONFIG_NODE)["properties"] description["nodes"]["charge-limit"] = { @@ -146,43 +167,35 @@ def _renamed_to_catalog(device_id: str) -> dict[str, dict[str, str]]: } tree[device_id]["$description"] = json.dumps(description) tree[device_id]["charge-limit/installer-max"] = tree[device_id].pop(CEILING_TOPIC) - tree[device_id]["charge-limit/owner-limit"] = tree[device_id].pop(LIMIT_TOPIC) + if LIMIT_TOPIC in tree[device_id]: + tree[device_id]["charge-limit/owner-limit"] = tree[device_id].pop(LIMIT_TOPIC) return tree -def _fed_adapter(*extra: tuple[str, str]) -> object: +def _fed_adapter(*extra: tuple[str, str]) -> SchemaAdapter: """A real schema_1 adapter fed the capture the way the broker replays it. - `extra` appends messages the retained-topic fixture cannot express — a - Homie `$target` is published on `//$target`, which the - fixture's flat `{topic: value}` shape has no room for. - """ - from span_panel_api.models import V2HomieSchema - from span_panel_api_schema_1 import SchemaOneAdapter + Through `captures_replay.replay`, the suite's one replay of a tree, so the + capture reaches the adapter the library dispatches to and under the rule + that replay applies to every value. - tree = schema_one_tree() - adapter = SchemaOneAdapter( - SCHEMA_ONE_PANEL, - V2HomieSchema( - firmware_version="spanos2/r202633/01", - types_schema_hash="sha256:test", - types={}, - data_model_version="1.0", - ), - ) - for device_id in [SCHEMA_ONE_PANEL, *[d for d in tree if d != SCHEMA_ONE_PANEL]]: - topics = tree[device_id] - prefix = f"ebus/5/{device_id}" - adapter.handle_message(f"{prefix}/$description", topics["$description"]) - adapter.handle_message(f"{prefix}/$state", topics["$state"]) - for topic, value in topics.items(): - if not topic.startswith("$"): - adapter.handle_message(f"{prefix}/{topic}", value) + `extra` then delivers messages as the broker would, each payload exactly as + given: a value a charger publishes after the capture was taken, a Homie + `$target`, published on `//$target`, which the fixture's + flat `{topic: value}` shape has no room for, or a zero-length payload, + which is a clear. + """ + adapter = replay(schema_one_tree()) for topic, value in extra: adapter.handle_message(topic, value) return adapter +def _wire(device_id: str, topic: str) -> str: + """The broker topic a charger publishes one of its properties on.""" + return f"{HOMIE_PREFIX}/{device_id}/{topic}" + + # --------------------------------------------------------------------------- # Building the entities # --------------------------------------------------------------------------- @@ -298,8 +311,11 @@ def drop(description: dict[str, dict[str, object]]) -> None: description["nodes"].pop(CONFIG_NODE) _mutated_description(tree, EVSE, drop) - del tree[EVSE][CEILING_TOPIC] - del tree[EVSE][LIMIT_TOPIC] + # Whatever the charger published under the node goes with its declaration: + # the ceiling, and a user limit wherever one had been set. + for topic in [topic for topic in tree[EVSE] if topic.startswith(f"{CONFIG_NODE}/")]: + del tree[EVSE][topic] + assert CEILING_TOPIC not in tree[EVSE] and LIMIT_TOPIC not in tree[EVSE] created = await _created(hass, schema_one_snapshot(tree)) @@ -312,14 +328,15 @@ def drop(description: dict[str, dict[str, object]]) -> None: async def test_each_number_reads_its_own_charger(hass: HomeAssistant) -> None: - """Read each charger's own limit, from values made to differ first. + """Read each charger's own limit, from values published and made to differ first. - The capture publishes 32 on both, so an assertion against it as-published is - satisfied by a platform that reads one charger twice. + The capture sets no limit on either charger and publishes the same ceiling + on both, so the state each shows as captured is satisfied by a platform that + reads one charger twice. """ tree = schema_one_tree() - first = _published(tree, EVSE, LIMIT_TOPIC) - 8 - second = _published(tree, EVSE_2, LIMIT_TOPIC) - 16 + first = _user_limit(tree, EVSE) + second = _user_limit(tree, EVSE_2) - 8 assert first != second created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: first}, **{"evse-2": {LIMIT_TOPIC: second}})) @@ -342,12 +359,17 @@ async def test_each_number_is_bounded_by_its_own_ceiling(hass: HomeAssistant) -> async def test_republishing_moves_the_state_and_the_bound(hass: HomeAssistant) -> None: + """A limit a user lowers and a ceiling an installer recommissions, on one entity. + + Starts from a limit the charger publishes, since the capture sets none. + """ tree = schema_one_tree() - created = await _created(hass, schema_one_snapshot(tree)) + first = _user_limit(tree, EVSE) + created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: first})) entity = _for(created, tree, EVSE) - assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC) + assert entity.native_value == first - lowered = _published(tree, EVSE, LIMIT_TOPIC) - 16 + lowered = first - 8 recommissioned = _published(tree, EVSE, CEILING_TOPIC) - 8 _refreshed(entity, _snapshot(evse={LIMIT_TOPIC: lowered, CEILING_TOPIC: recommissioned})) @@ -355,6 +377,58 @@ async def test_republishing_moves_the_state_and_the_bound(hass: HomeAssistant) - assert entity.native_max_value == recommissioned +async def test_the_capture_sets_no_user_limit_and_each_number_shows_its_ceiling( + hass: HomeAssistant, +) -> None: + """The capture's own state, end to end, on both chargers. + + Each charger declares the limit settable and publishes no value for it, which + is r202639 with no user limit set. The control is still offered, shows the + ceiling as the limit in force, is bounded by it, and has nothing pending. + """ + tree = schema_one_tree() + created = await _created(hass, schema_one_snapshot(tree)) + + for device_id in (EVSE, EVSE_2): + assert _declaration(tree, device_id, "user-max-charge-current")["settable"] is True + assert LIMIT_TOPIC not in tree[device_id] + ceiling = _published(tree, device_id, CEILING_TOPIC) + + entity = _for(created, tree, device_id) + + assert entity.native_value == ceiling + assert entity.native_max_value == ceiling + assert entity.available is True + assert entity.extra_state_attributes is None + + +async def test_a_limit_set_and_cleared_moves_the_state_off_the_ceiling_and_back( + hass: HomeAssistant, +) -> None: + """The user limit appearing and going away on a live entity, through the transport. + + A user setting a limit makes the charger publish one; removing it clears the + retained value with a zero-length payload, which the library reads as + unpublished. The control follows both, back to the ceiling rather than + holding the last limit it saw. + """ + tree = schema_one_tree() + ceiling = _published(tree, EVSE, CEILING_TOPIC) + limit = _user_limit(tree, EVSE) + adapter = _fed_adapter() + entity = _for(await _created(hass, adapter.build_snapshot()), tree, EVSE) + assert entity.native_value == ceiling + + adapter.handle_message(_wire(EVSE, LIMIT_TOPIC), str(limit)) + _refreshed(entity, adapter.build_snapshot()) + assert entity.native_value == limit + + adapter.handle_message(_wire(EVSE, LIMIT_TOPIC), "") + _refreshed(entity, adapter.build_snapshot()) + assert entity.native_value == ceiling + assert entity.available is True + + async def test_an_unpublished_limit_shows_the_ceiling(hass: HomeAssistant) -> None: """No user limit means the commissioned ceiling is the limit in force. @@ -442,22 +516,29 @@ async def test_the_control_is_unavailable_while_the_panel_is_offline(hass: HomeA # --------------------------------------------------------------------------- -async def test_a_pending_write_is_an_attribute_and_not_the_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("limit_set", [False, True], ids=["no-user-limit", "user-limit-set"]) +async def test_a_pending_write_is_an_attribute_and_not_the_state( + hass: HomeAssistant, limit_set: bool +) -> None: """The `$target` echo, rendered the way the priority select renders its own. Reporting the requested value as the state would show a limit the charger - may never have accepted. + may never have accepted. Asserted with no user limit set, as captured, where + the state is the ceiling, and with one set, where it is that limit: a write + in flight replaces neither. """ tree = schema_one_tree() - pending = _published(tree, EVSE, LIMIT_TOPIC) - 8 - adapter = _fed_adapter((f"ebus/5/{EVSE}/{LIMIT_TOPIC}/$target", str(pending))) + enforced = _user_limit(tree, EVSE) if limit_set else _published(tree, EVSE, CEILING_TOPIC) + pending = enforced - 8 + published = [(_wire(EVSE, LIMIT_TOPIC), str(enforced))] if limit_set else [] + adapter = _fed_adapter(*published, (_wire(EVSE, f"{LIMIT_TOPIC}/$target"), str(pending))) snapshot = adapter.build_snapshot() created = await _created(hass, snapshot) entity = _for(created, tree, EVSE) assert entity.extra_state_attributes == {"charge_current_limit_target": pending} - assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC) + assert entity.native_value == enforced assert _for(created, tree, EVSE_2).extra_state_attributes is None @@ -466,17 +547,36 @@ async def test_a_pending_write_is_an_attribute_and_not_the_state(hass: HomeAssis # --------------------------------------------------------------------------- -async def test_the_catalogued_spelling_produces_the_same_control(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("limit_set", [False, True], ids=["no-user-limit", "user-limit-set"]) +async def test_the_catalogued_spelling_produces_the_same_control( + hass: HomeAssistant, limit_set: bool +) -> None: """`charge-limit/{installer-max,owner-limit}` — the eBus 0.1 naming. Nothing in this integration names either spelling, so a charger publishing the specified one has to produce an identical entity. Asserted against the unrenamed capture rather than against literals, so the two are held to each - other. + other: as captured, where the state comes from the ceiling, and with a user + limit published, where it comes from the limit, so both renamed properties + are read. """ - tree = schema_one_tree() + + def tree_as_published() -> dict[str, dict[str, str]]: + tree = schema_one_tree() + if limit_set: + tree[EVSE][LIMIT_TOPIC] = str(_user_limit(tree, EVSE)) + return tree + + tree = tree_as_published() published = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) - catalogued = _for(await _created(hass, schema_one_snapshot(_renamed_to_catalog(EVSE))), tree, EVSE) + catalogued = _for( + await _created(hass, schema_one_snapshot(_renamed_to_catalog(tree_as_published(), EVSE))), + tree, + EVSE, + ) + + expected = _user_limit(tree, EVSE) if limit_set else _published(tree, EVSE, CEILING_TOPIC) + assert published.native_value == expected assert catalogued.unique_id == published.unique_id assert catalogued.native_value == published.native_value diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py index 11f3e3cd..90ea1903 100644 --- a/tests/test_sensor_entities.py +++ b/tests/test_sensor_entities.py @@ -73,6 +73,7 @@ ) from .adapter_fixtures import schema_one_snapshot, schema_one_tree, schema_zero_snapshot +from .captures_replay import snapshot as replayed_snapshot from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, @@ -274,6 +275,28 @@ def test_the_topology_attribute_still_says_true_when_true_is_a_reading() -> None assert sensor.extra_state_attributes["at_service_entrance"] is True +def test_the_empty_feed_is_still_a_reading_when_the_broker_replays_it() -> None: + """The same empty value, delivered message by message as the broker replays it. + + On that path a zero-length payload is a clear, which the library reads as + unpublished and the attribute would then omit. A tree holds the decoded + value, so `captures_replay` replays `""` as Homie 5's empty value, one + `0x00`, and the panel reads as at the service entrance exactly as it does + built straight from the mapper above. + """ + tree = schema_one_tree() + tree["lugs-upstream"]["connection/fed-by-device-id"] = "" + snapshot = replayed_snapshot(tree) + assert snapshot.lugs_at_service_entrance is True + coordinator = _make_coordinator(snapshot, schema_major="schema_1") + description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW") + + sensor = SpanPanelPowerSensor(coordinator, description, snapshot) + sensor._update_native_value() + + assert sensor.extra_state_attributes["at_service_entrance"] is True + + def test_the_topology_attribute_is_omitted_while_the_lugs_have_not_said() -> None: """Declared and not yet published, the feed may still name a device. From 6d0e7bb2fe8296ddc8e39da73f2436d9b1afe5db Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:57:51 -0700 Subject: [PATCH 2/8] feat(diagnostics): include the panel's masked device tree --- CHANGELOG.md | 2 + custom_components/span_panel/diagnostics.py | 47 ++++++++++- tests/captures_replay.py | 15 +++- tests/test_diagnostics.py | 93 +++++++++++++++++++-- tests/test_privacy.py | 22 ++++- 5 files changed, 167 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c94e74dd..e08090c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file. panel publishes them. - **A battery the panel declares gets its device before its first reading**, reading unknown until then, and a battery fed by a circuit reports its connection. - **Registration says when to retry** if the panel limits attempts, and setup asks only for the passphrase where the panel does not support proximity. +- **Diagnostics include the panel's device tree**: every device and property the panel publishes, with serial numbers, device ids and site details replaced, so a + report can be looked into without a separate capture. ### Changed diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py index a8c6f7e3..0c215020 100644 --- a/custom_components/span_panel/diagnostics.py +++ b/custom_components/span_panel/diagnostics.py @@ -3,13 +3,19 @@ from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from span_panel_api import SpanPanelSnapshot, ca_fingerprint, shared_meter_groups +from span_panel_api import ( + MaskedTree, + SpanMqttClient, + SpanPanelSnapshot, + ca_fingerprint, + shared_meter_groups, +) from span_panel_api.exceptions import SpanPanelValidationError from .adoption import adopted_control_count, classify @@ -161,6 +167,40 @@ def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock: } +class DeviceTreeBlock(TypedDict): + """The `device_tree` section and, when there is no tree, why. + + `device_tree_unavailable` is absent when the tree is present, so a reader + tells "this panel has no tree to give" from "the export was dropped from the + report" by the key rather than by guessing at a bare `None`. + """ + + device_tree: MaskedTree | None + device_tree_unavailable: NotRequired[Literal["flat_data_model", "not_described"]] + + +def _device_tree(client: SpanMqttClient) -> DeviceTreeBlock: + """Return the panel's device tree as the library masks it, or the reason there is none. + + **Masked by the library, not here.** Serials, device ids and site details are + replaced where the tree is exported, so this function adds nothing to the + payload that the library has not already made safe: it forwards the export + whole and never reads a wire value itself. + + The tree is the adapter's last view, so a panel that has gone offline still + reports it -- that is the report a maintainer most needs. `flat_data_model` + is a schema 0 panel, which publishes no device tree; `not_described` is a + parent/child panel whose root device has not described itself yet. + """ + tree = client.masked_tree() + if tree is not None: + return {"device_tree": tree} + reason: Literal["flat_data_model", "not_described"] = ( + "flat_data_model" if client.schema_major == "schema_0" else "not_described" + ) + return {"device_tree": None, "device_tree_unavailable": reason} + + class AdoptedDeviceRow(TypedDict): """One adopted device, as it appears in the payload. @@ -444,6 +484,9 @@ async def async_get_config_entry_diagnostics( "last_update_success": coordinator.last_update_success, }, "schema_discovery": _discovery(coordinator.schema_findings), + # Masked by the library and safe by construction, the rule `TO_REDACT`'s + # docstring states: nothing here is redacted again. + **_device_tree(coordinator.client), "adopted_devices": _adoption(snapshot), # Keys and enum values only -- no wire values, no user free text (names # and icons live in Core's registry, not here). Same withholding rules diff --git a/tests/captures_replay.py b/tests/captures_replay.py index b2c2f5c7..c0aec775 100644 --- a/tests/captures_replay.py +++ b/tests/captures_replay.py @@ -26,7 +26,13 @@ from ebus_panel_sim import Tree, load_reference_capture from ebus_sdk import encode_empty_string -from span_panel_api import ControlInterceptor, LeafNameMismatch, SpanPanelSnapshot +from span_panel_api import ( + ControlInterceptor, + LeafNameMismatch, + MaskedTree, + SpanPanelSnapshot, + TreeExportingAdapter, +) from span_panel_api.adapters import resolve_adapter from span_panel_api.dispatch import select_adapter_key from span_panel_api.exceptions import SpanPanelError @@ -265,6 +271,13 @@ def schema_major(self) -> str: def field_metadata(self) -> dict[str, FieldMetadata]: return self._adapter.build_field_metadata() + def masked_tree(self) -> MaskedTree | None: + """The library's masked export of the replayed tree, as `SpanMqttClient` offers it.""" + adapter = self._adapter + if isinstance(adapter, TreeExportingAdapter): + return adapter.export_masked_tree() + return None + async def connect(self) -> None: """Connected already: the replay is the retained tree a connect receives.""" diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a937b9ae..b2e706f3 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from pytest_homeassistant_custom_component.common import MockConfigEntry -from span_panel_api import SpanPanelSnapshot, SpanPVSnapshot +from span_panel_api import MaskedTree, SpanPanelSnapshot, SpanPVSnapshot, TreeExportingAdapter from custom_components.span_panel import SpanPanelRuntimeData from custom_components.span_panel.const import ( @@ -35,7 +35,8 @@ from custom_components.span_panel.helpers import identity_digest from custom_components.span_panel.pv_binding import PvBinding, StoredPvBinding, resolve -from .adapter_fixtures import schema_one_snapshot +from .adapter_fixtures import schema_one_snapshot, schema_one_tree +from .captures_replay import replay from .factories import ( SpanBatterySnapshotFactory, SpanCircuitSnapshotFactory, @@ -85,6 +86,8 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data( # this unset would let the discovery block render as an empty report rather # than as the "no metadata yet" state it actually is. coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None # Version 6 deliberately: an entry that has not yet been through the v7 # migration still carries the passphrase, and redaction must still cover it. @@ -200,6 +203,8 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable coordinator.panel_offline = True coordinator.last_update_success = False coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel") entry.runtime_data = SpanPanelRuntimeData( @@ -272,6 +277,8 @@ async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> N coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None # The real runtime data, not a namespace, for the reason the circuit double # above is the real model: a hand-rolled stand-in carrying only the fields # the dump happened to read when it was written goes stale silently, and @@ -321,6 +328,8 @@ async def test_diagnostics_reports_the_stored_curation(hass: HomeAssistant) -> N coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, panel_device_id="panel-device-id", @@ -356,6 +365,8 @@ async def test_diagnostics_reports_which_inverter_the_solar_card_reads(hass: Hom coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None identity = pv_binding_for(snapshot) entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, @@ -404,6 +415,8 @@ async def test_diagnostics_says_an_unbound_card_reads_several_inverters_together coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, panel_device_id="panel-device-id", @@ -431,6 +444,8 @@ async def _pv_block(hass: HomeAssistant, entry_id: str, snapshot: SpanPanelSnaps coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, panel_device_id="panel-device-id", @@ -508,14 +523,35 @@ async def test_a_withheld_inverters_device_id_key_is_digested(hass: HomeAssistan # -- site data never leaves the house in a dump ------------------------------ -def _reference_entry(snapshot: SpanPanelSnapshot) -> MockConfigEntry: - """Return an entry whose coordinator holds `snapshot`, as diagnostics reads it.""" +def _export(tree: dict[str, dict[str, str]]) -> MaskedTree: + """The masked export the library produces for `tree`, replayed as the broker delivers it.""" + adapter = replay(tree) + assert isinstance(adapter, TreeExportingAdapter) + export = adapter.export_masked_tree() + assert export is not None + return export + + +def _reference_entry( + snapshot: SpanPanelSnapshot, + tree: dict[str, dict[str, str]] | None = None, + *, + schema_major: str = "schema_1", + panel_offline: bool = False, +) -> MockConfigEntry: + """Return an entry whose coordinator holds `snapshot`, as diagnostics reads it. + + Given `tree`, the client exports the library's real masked tree for it, so a + test of what reaches the dump is a test of what the library masks. + """ coordinator = MagicMock() coordinator.data = snapshot - coordinator.panel_offline = False + coordinator.panel_offline = panel_offline coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = schema_major + coordinator.client.masked_tree.return_value = None if tree is None else _export(tree) entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id=snapshot.serial_number) entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, @@ -536,7 +572,7 @@ async def test_diagnostics_redact_the_wifi_network_name(hass: HomeAssistant) -> snapshot = schema_one_snapshot() assert snapshot.wifi_ssid - result = await async_get_config_entry_diagnostics(hass, _reference_entry(snapshot)) + result = await async_get_config_entry_diagnostics(hass, _reference_entry(snapshot, schema_one_tree())) assert result["panel"]["wifi_ssid"] == REDACTED assert snapshot.wifi_ssid not in json.dumps(result, default=str) @@ -555,7 +591,7 @@ async def test_diagnostics_hold_no_postal_code(hass: HomeAssistant) -> None: ) assert postal_code.value - entry = _reference_entry(snapshot) + entry = _reference_entry(snapshot, schema_one_tree()) result = await async_get_config_entry_diagnostics(hass, entry) assert postal_code.value not in json.dumps(result, default=str) @@ -587,3 +623,46 @@ async def test_diagnostics_hold_no_postal_code(hass: HomeAssistant) -> None: if (sensor.extra_state_attributes or {}).get("wire_path") == postal_code.path ) assert postal_code_sensor.entity_registry_enabled_default is False + + +# -- the masked device tree -------------------------------------------------- + + +async def test_diagnostics_carry_the_masked_device_tree(hass: HomeAssistant) -> None: + """The report carries the library's masked export as it is, so a case needs no separate capture.""" + snapshot = schema_one_snapshot() + export = _export(schema_one_tree()) + + result = await async_get_config_entry_diagnostics(hass, _reference_entry(snapshot, schema_one_tree())) + + assert result["device_tree"] == export + assert "device_tree_unavailable" not in result + + +async def test_diagnostics_say_a_flat_panel_has_no_tree(hass: HomeAssistant) -> None: + """A flat data model has no device tree to export, and the report says that is why.""" + entry = _reference_entry(schema_one_snapshot(), schema_major="schema_0") + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["device_tree"] is None + assert result["device_tree_unavailable"] == "flat_data_model" + + +async def test_diagnostics_say_an_undescribed_panel_has_no_tree(hass: HomeAssistant) -> None: + """A parent/child panel that has not described its root yet has nothing to export.""" + result = await async_get_config_entry_diagnostics(hass, _reference_entry(schema_one_snapshot())) + + assert result["device_tree"] is None + assert result["device_tree_unavailable"] == "not_described" + + +async def test_an_offline_panel_still_reports_its_last_tree(hass: HomeAssistant) -> None: + """A panel that dropped off is the case a report is written about, so its last tree stays.""" + export = _export(schema_one_tree()) + entry = _reference_entry(schema_one_snapshot(), schema_one_tree(), panel_offline=True) + + result = await async_get_config_entry_diagnostics(hass, entry) + + assert result["coordinator"]["panel_offline"] is True + assert result["device_tree"] == export diff --git a/tests/test_privacy.py b/tests/test_privacy.py index a4e5a973..9abfe3dc 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -74,6 +74,22 @@ def _with_site() -> RetainedTree: return tree +EXPORT_KEEPS: Final = ("info/country-code", "status/time-zone") +"""Site properties the masked device tree keeps as published, because they can matter to a firmware question.""" + +EXPORT_MASKS: Final = frozenset( + {"Masked Address", "Masked City", "Masked Region", "Masked Panel", "masked-ssid", "MASKED-METER"} +) +"""What the masked device tree writes where it hides a site value; a capture already carrying one cannot tell the two apart.""" + + +def _in_diagnostics(tree: RetainedTree, values: list[str]) -> list[str]: + """The site values a dump must not hold: those the masked device tree does not keep or write itself.""" + panel = tree[panel_device_id(tree)] + kept = {panel[path] for path in EXPORT_KEEPS if path in panel} + return [value for value in values if value not in kept and value not in EXPORT_MASKS] + + def _site_reading_values(tree: RetainedTree) -> list[str]: panel = tree[panel_device_id(tree)] values = [panel[path] for path in SITE_READINGS] @@ -116,8 +132,9 @@ async def test_no_site_value_reaches_an_entity_diagnostics_or_the_log( for value in (*SITE_VALUES.values(), *_site_reading_values(tree)): assert value not in states, value - assert value not in diagnostics, value assert value not in logged, value + for value in _in_diagnostics(tree, [*SITE_VALUES.values(), *_site_reading_values(tree)]): + assert value not in diagnostics, value async def test_a_new_install_creates_neither_site_reading(hass: HomeAssistant) -> None: @@ -200,5 +217,6 @@ async def test_no_captured_site_value_reaches_an_entity_diagnostics_or_the_log( for value in searchable: assert value not in states, value - assert value not in diagnostics, value assert value not in logged, value + for value in _in_diagnostics(tree, searchable): + assert value not in diagnostics, value From 7b9768c3813811cf0902a2da24ae155ce8857a1c Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:10:04 -0700 Subject: [PATCH 3/8] test: check the diagnostics site values outside and inside the masked tree --- tests/test_privacy.py | 64 ++++++++++++++++++++++++++-------- tests/test_schema_discovery.py | 2 ++ tests/test_shared_meters.py | 2 ++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 9abfe3dc..1c587870 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -74,20 +74,48 @@ def _with_site() -> RetainedTree: return tree -EXPORT_KEEPS: Final = ("info/country-code", "status/time-zone") -"""Site properties the masked device tree keeps as published, because they can matter to a firmware question.""" +EXPORT_MASKS: Final = { + "address-lines": "Masked Address", + "locality": "Masked City", + "region": "Masked Region", + "country-code": "ZZ", + "latitude": "0.0", + "longitude": "0.0", + "utility-meter-serial-number": "MASKED-METER", + "postal-code": "00000", + "time-zone": "UTC", + "wifi-ssid": "masked-ssid", + "name": "Masked Panel", +} +"""What the masked device tree writes for each site property, by the property's id on any node.""" + + +def _without_the_tree(diagnostics: dict[str, object]) -> str: + """The dump with `device_tree` taken out, which is where no site value may appear at all.""" + return json.dumps({k: v for k, v in diagnostics.items() if k != "device_tree"}, default=str) + + +def _tree_text(diagnostics: dict[str, object]) -> str: + return json.dumps(diagnostics["device_tree"], default=str) -EXPORT_MASKS: Final = frozenset( - {"Masked Address", "Masked City", "Masked Region", "Masked Panel", "masked-ssid", "MASKED-METER"} -) -"""What the masked device tree writes where it hides a site value; a capture already carrying one cannot tell the two apart.""" +def _unmasked_in_the_tree(tree: RetainedTree, values: list[str]) -> list[str]: + """The site values the tree must not hold: all but one that is the export's own mask for the property it came from. -def _in_diagnostics(tree: RetainedTree, values: list[str]) -> list[str]: - """The site values a dump must not hold: those the masked device tree does not keep or write itself.""" + A capture already masked upstream publishes a value equal to the library's + mask, and the tree cannot be told to hold it or not; keyed by property, so a + value the mask of a different property would explain is still searched for. + """ panel = tree[panel_device_id(tree)] - kept = {panel[path] for path in EXPORT_KEEPS if path in panel} - return [value for value in values if value not in kept and value not in EXPORT_MASKS] + leaves = { + value: {topic.rsplit("/", 1)[-1] for topic, v in panel.items() if v == value} + for value in values + } + return [ + value + for value in values + if not leaves[value] or any(EXPORT_MASKS.get(leaf) != value for leaf in leaves[value]) + ] def _site_reading_values(tree: RetainedTree) -> list[str]: @@ -121,7 +149,9 @@ async def test_no_site_value_reaches_an_entity_diagnostics_or_the_log( caplog.set_level(logging.DEBUG) entry = await _install(hass, tree, "site") - diagnostics = json.dumps(await async_get_config_entry_diagnostics(hass, entry), default=str) + payload = await async_get_config_entry_diagnostics(hass, entry) + diagnostics = _without_the_tree(payload) + tree_text = _tree_text(payload) states = json.dumps( [(state.state, dict(state.attributes)) for state in hass.states.async_all()], default=str ) @@ -133,8 +163,10 @@ async def test_no_site_value_reaches_an_entity_diagnostics_or_the_log( for value in (*SITE_VALUES.values(), *_site_reading_values(tree)): assert value not in states, value assert value not in logged, value - for value in _in_diagnostics(tree, [*SITE_VALUES.values(), *_site_reading_values(tree)]): + for value in (*SITE_VALUES.values(), *_site_reading_values(tree)): assert value not in diagnostics, value + for value in _unmasked_in_the_tree(tree, [*SITE_VALUES.values(), *_site_reading_values(tree)]): + assert value not in tree_text, value async def test_a_new_install_creates_neither_site_reading(hass: HomeAssistant) -> None: @@ -206,7 +238,9 @@ async def test_no_captured_site_value_reaches_an_entity_diagnostics_or_the_log( caplog.set_level(logging.DEBUG) entry = await _install(hass, tree, captured.name) - diagnostics = json.dumps(await async_get_config_entry_diagnostics(hass, entry), default=str) + payload = await async_get_config_entry_diagnostics(hass, entry) + diagnostics = _without_the_tree(payload) + tree_text = _tree_text(payload) states = json.dumps( [(state.state, dict(state.attributes)) for state in hass.states.async_all()], default=str ) @@ -218,5 +252,7 @@ async def test_no_captured_site_value_reaches_an_entity_diagnostics_or_the_log( for value in searchable: assert value not in states, value assert value not in logged, value - for value in _in_diagnostics(tree, searchable): + for value in searchable: assert value not in diagnostics, value + for value in _unmasked_in_the_tree(tree, searchable): + assert value not in tree_text, value diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py index 3645b192..00f20397 100644 --- a/tests/test_schema_discovery.py +++ b/tests/test_schema_discovery.py @@ -187,6 +187,8 @@ def _entry(findings: SchemaFindings | None) -> MockConfigEntry: coordinator.transport_dead = False coordinator.last_update_success = True coordinator.schema_findings = findings + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id="example-40t-001") entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, diff --git a/tests/test_shared_meters.py b/tests/test_shared_meters.py index 66e0d627..0fb975d5 100644 --- a/tests/test_shared_meters.py +++ b/tests/test_shared_meters.py @@ -209,6 +209,8 @@ async def test_diagnostics_list_one_row_per_group(hass: HomeAssistant) -> None: coordinator.panel_offline = False coordinator.last_update_success = True coordinator.schema_findings = None + coordinator.client.schema_major = "schema_1" + coordinator.client.masked_tree.return_value = None entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id=SERIAL) entry.runtime_data = SpanPanelRuntimeData( coordinator=coordinator, From 736276dc1506e38787f3215e3153d22344e9fe0e Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:18:22 -0700 Subject: [PATCH 4/8] fix(number): bound the charge limit by the range the charger declares --- CHANGELOG.md | 1 + custom_components/span_panel/number.py | 51 ++++++++-- tests/test_evse_charge_limit.py | 127 ++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e08090c9..978fd81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ All notable changes to this project will be documented in this file. - **Diagnostics redact the Wi-Fi network name.** - **Each update from a panel on firmware r202633 or later costs Home Assistant's event loop the same small amount however many circuits and devices it has**, instead of growing with each one ([span-panel-api#223](https://github.com/SpanPanel/span-panel-api/issues/223)). +- **The EV charger charge-limit number offers only the range the charger accepts**, where it offered values the charger refuses. ## [2.1.2] - 10/2026 diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 3b6391e7..ea17e44f 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -64,6 +64,9 @@ PARALLEL_UPDATES = 1 +_FALLBACK_MINIMUM_A: Final = 0 +"""The minimum for a charger that declares none, in amps.""" + @dataclass(frozen=True) class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin): @@ -71,6 +74,8 @@ class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin): value_fn: Callable[[SpanEvseSnapshot], int | None] maximum_fn: Callable[[SpanEvseSnapshot], int | None] + minimum_fn: Callable[[SpanEvseSnapshot], int | None] + declared_maximum_fn: Callable[[SpanEvseSnapshot], int | None] target_fn: Callable[[SpanEvseSnapshot], int | None] settable_fn: Callable[[SpanEvseSnapshot], bool] set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[PublishOutcome]] @@ -94,13 +99,14 @@ class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberReq translation_key="evse_charge_current_limit", device_class=NumberDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - # Zero, because a charge-current ceiling has no meaning below it: this is a - # charge-only EVSE, so lowering the ceiling can stop charging and can never - # reverse it. Not read from the wire because there is nothing on the wire to - # read — Homie expresses a bounded numeric as a `min:max` `$format`, and - # neither the capture nor the `charge-limit` catalog declares one for this - # property. See `native_max_value`, which *is* published and is read. - native_min_value=0, + # Zero is the fallback, used only for a charger that declares no minimum for + # the limit: a charge-current ceiling has no meaning below it, since this is + # a charge-only EVSE. A charger that declares one (the capture's `6:32` + # `$format`) is bounded by it instead, because the library refuses a limit + # outside the declared range; `SpanEvseNumber._apply` reads it each update + # through `minimum_fn`, and the maximum through `maximum_fn` and + # `declared_maximum_fn`. + native_min_value=_FALLBACK_MINIMUM_A, # The declared datatype is `integer`, and a step is the granularity of the # quantity rather than a policy: a charger that accepts 16 A and 17 A does # not accept 16.5. Asserted against the declaration in @@ -113,6 +119,14 @@ class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberReq # what it enforces. Showing unknown there would hide a real setting. value_fn=lambda evse: evse.effective_charge_current_limit_a, maximum_fn=lambda evse: evse.charge_current_ceiling_a, + # The declared range applies only where the limit is settable; where it is + # not, the control keeps its fallback bounds. + minimum_fn=lambda evse: ( + evse.charge_current_limit_minimum_a if evse.charge_current_limit_settable else None + ), + declared_maximum_fn=lambda evse: ( + evse.charge_current_limit_maximum_a if evse.charge_current_limit_settable else None + ), target_fn=lambda evse: evse.charge_current_limit_target_a, settable_fn=lambda evse: evse.charge_current_limit_settable, set_fn=lambda client, node_id, amps: client.set_evse_charge_limit(node_id, amps), @@ -204,11 +218,28 @@ def _apply(self, evse: SpanEvseSnapshot) -> None: hardware is no longer rated for. Left at whatever was last published when the value goes away, so the entity reports unavailable with its last known bound rather than briefly widening. + + The range the charger declares for its limit bounds the control too, + because the library refuses a value outside it: the minimum is the + declared one, else the description's fallback; the maximum is the lower + of the declared maximum and the ceiling, each where present. An open + side falls back for that side only. """ self._attr_native_value = self._description.value_fn(evse) - maximum = self._description.maximum_fn(evse) - if maximum is not None: - self._attr_native_max_value = maximum + declared_minimum = self._description.minimum_fn(evse) + self._attr_native_min_value = ( + _FALLBACK_MINIMUM_A if declared_minimum is None else declared_minimum + ) + ceilings = [ + bound + for bound in ( + self._description.maximum_fn(evse), + self._description.declared_maximum_fn(evse), + ) + if bound is not None + ] + if ceilings: + self._attr_native_max_value = min(ceilings) @property def available(self) -> bool: diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 484a3e70..7b6c798a 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -145,6 +145,24 @@ def drop(description: dict[str, dict[str, dict[str, dict[str, dict[str, object]] return schema_one_snapshot(_mutated_description(schema_one_tree(), device_id, drop)) +def _declared_range(device_id: str, range_format: str | None) -> dict[str, dict[str, str]]: + """The capture with one charger's user limit declaring `range_format`, or none. + + `None` removes the `$format`, the charger that declares no range; anything + else replaces it, as a charger commissioned with another range would. + """ + tree = schema_one_tree() + + def declare(description: dict[str, dict[str, dict[str, dict[str, dict[str, object]]]]]) -> None: + declared = description["nodes"][CONFIG_NODE]["properties"]["user-max-charge-current"] + if range_format is None: + declared.pop("format") + else: + declared["format"] = range_format + + return _mutated_description(tree, device_id, declare) + + def _renamed_to_catalog(tree: dict[str, dict[str, str]], device_id: str) -> dict[str, dict[str, str]]: """`tree` with one charger publishing the eBus `charge-limit` spelling, in place. @@ -615,9 +633,116 @@ def test_the_control_is_configuration_rather_than_measurement() -> None: assert EVSE_CHARGE_CURRENT_LIMIT.entity_category is EntityCategory.CONFIG assert EVSE_CHARGE_CURRENT_LIMIT.device_class is NumberDeviceClass.CURRENT assert EVSE_CHARGE_CURRENT_LIMIT.mode is NumberMode.BOX + + +def test_the_description_minimum_is_only_the_fallback_for_an_undeclared_range() -> None: + """Zero is what a charger that declares no minimum is offered, not the rule. + + The entity's minimum is the one the charger declares; see the range tests + below, which read it off the entity rather than the description. + """ assert EVSE_CHARGE_CURRENT_LIMIT.native_min_value == 0 +# --------------------------------------------------------------------------- +# The range the charger declares +# --------------------------------------------------------------------------- + + +async def test_the_declared_range_bounds_the_control(hass: HomeAssistant) -> None: + """The capture declares `6:32`: the library refuses 0-5 A, so the control does not offer it.""" + tree = schema_one_tree() + assert _declaration(tree, EVSE, "user-max-charge-current")["format"] == "6:32" + ceiling = _published(tree, EVSE, CEILING_TOPIC) + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + + assert entity.native_min_value == 6 + assert entity.native_max_value == min(32, ceiling) + + +async def test_a_charger_declaring_no_range_keeps_the_old_bounds(hass: HomeAssistant) -> None: + tree = _declared_range(EVSE, None) + ceiling = _published(tree, EVSE, CEILING_TOPIC) + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + + assert entity.native_min_value == 0 + assert entity.native_max_value == ceiling + + +async def test_a_declared_maximum_below_the_ceiling_bounds_the_maximum(hass: HomeAssistant) -> None: + tree = _declared_range(EVSE, "6:24") + ceiling = _published(tree, EVSE, CEILING_TOPIC) + assert ceiling > 24 + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + + assert entity.native_min_value == 6 + assert entity.native_max_value == 24 + + +async def test_a_ceiling_below_the_declared_maximum_still_bounds_the_maximum( + hass: HomeAssistant, +) -> None: + tree = _declared_range(EVSE, "6:40") + ceiling = _published(tree, EVSE, CEILING_TOPIC) + assert ceiling < 40 + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + + assert entity.native_max_value == ceiling + + +@pytest.mark.parametrize( + ("range_format", "minimum", "maximum"), + [("6:", 6, None), (":24", 0, 24)], + ids=["open-maximum", "open-minimum"], +) +async def test_an_open_side_falls_back_for_that_side_only( + hass: HomeAssistant, range_format: str, minimum: int, maximum: int | None +) -> None: + tree = _declared_range(EVSE, range_format) + snapshot = schema_one_snapshot(tree) + declared = snapshot.evse[_serial(tree, EVSE)] + assert (declared.charge_current_limit_minimum_a, declared.charge_current_limit_maximum_a) == ( + (minimum or None), + maximum, + ) + ceiling = _published(tree, EVSE, CEILING_TOPIC) + entity = _for(await _created(hass, snapshot), tree, EVSE) + + assert entity.native_min_value == minimum + assert entity.native_max_value == (ceiling if maximum is None else maximum) + + +async def test_the_range_follows_the_charger_when_it_is_recommissioned(hass: HomeAssistant) -> None: + """The declared bounds are read on every update, as the ceiling is.""" + tree = schema_one_tree() + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + assert (entity.native_min_value, entity.native_max_value) == (6, 32) + + _refreshed(entity, schema_one_snapshot(_declared_range(EVSE, "10:20"))) + assert (entity.native_min_value, entity.native_max_value) == (10, 20) + + _refreshed(entity, schema_one_snapshot(_declared_range(EVSE, None))) + assert (entity.native_min_value, entity.native_max_value) == (0, _published(tree, EVSE, CEILING_TOPIC)) + + +def test_the_range_is_applied_only_where_the_limit_is_settable() -> None: + """Where the limit is not settable the entity keeps its old bounds. + + Such a charger gets no entity from the platform; the description is asked + directly, since an entity that must not exist cannot be asked for its range. + """ + tree = schema_one_tree() + settable = schema_one_snapshot(tree).evse[_serial(tree, EVSE)] + refused = _not_settable(EVSE).evse[_serial(tree, EVSE)] + assert settable.charge_current_limit_settable is True + assert refused.charge_current_limit_settable is False + + assert EVSE_CHARGE_CURRENT_LIMIT.minimum_fn(settable) == 6 + assert EVSE_CHARGE_CURRENT_LIMIT.declared_maximum_fn(settable) == 32 + assert EVSE_CHARGE_CURRENT_LIMIT.minimum_fn(refused) is None + assert EVSE_CHARGE_CURRENT_LIMIT.declared_maximum_fn(refused) is None + + # --------------------------------------------------------------------------- # Writing # --------------------------------------------------------------------------- @@ -787,7 +912,7 @@ async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: Hom entity = _for(created, tree, EVSE) assert entity.native_max_value == _published(tree, EVSE, CEILING_TOPIC) - assert entity.native_min_value == 0 + assert entity.native_min_value == 6 # --------------------------------------------------------------------------- From 86cf86af68d701c0beca475ac3fd6d03a13197fa Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:21:08 -0700 Subject: [PATCH 5/8] test: plant a distinct country code in the privacy test --- tests/test_privacy.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 1c587870..9df75d02 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -36,7 +36,7 @@ "address-lines": "1 Example Street", "locality": "Exampleville", "region": "EX", - "country-code": "ZZ", + "country-code": "XE", "latitude": "12.3456", "longitude": "-65.4321", "utility-meter-serial-number": "UMS-EXAMPLE-0001", @@ -87,7 +87,10 @@ def _with_site() -> RetainedTree: "wifi-ssid": "masked-ssid", "name": "Masked Panel", } -"""What the masked device tree writes for each site property, by the property's id on any node.""" +"""What the masked device tree writes for each site property, by the property's id on any node. + +`name` is the exception: only the panel's own `info/name` is masked, never another device's. +""" def _without_the_tree(diagnostics: dict[str, object]) -> str: From ec0a75b7950af572ede50382764e8984cbafdad3 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:23:22 -0700 Subject: [PATCH 6/8] fix(number): drop the declared range when unsettable and clamp the minimum to the maximum --- custom_components/span_panel/number.py | 16 ++++-- tests/test_evse_charge_limit.py | 67 ++++++++++++++++++-------- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index ea17e44f..4a72829d 100644 --- a/custom_components/span_panel/number.py +++ b/custom_components/span_panel/number.py @@ -215,15 +215,17 @@ def _apply(self, evse: SpanEvseSnapshot) -> None: The maximum moves with the panel because it can: an installer recommissioning a charger republishes `installer-max`, and a control still offering the old range would let a user ask for a current the - hardware is no longer rated for. Left at whatever was last published - when the value goes away, so the entity reports unavailable with its - last known bound rather than briefly widening. + hardware is no longer rated for. When neither the ceiling nor a declared + maximum is published the maximum is left at whatever it last was, and + the entity is unavailable rather than briefly widening. The range the charger declares for its limit bounds the control too, because the library refuses a value outside it: the minimum is the declared one, else the description's fallback; the maximum is the lower - of the declared maximum and the ceiling, each where present. An open - side falls back for that side only. + of the declared maximum and the ceiling, each where present, so a + declared maximum alone bounds it while the ceiling is unpublished (the + entity is unavailable then). An open side falls back for that side only, + and the range is applied only where the limit is settable. """ self._attr_native_value = self._description.value_fn(evse) declared_minimum = self._description.minimum_fn(evse) @@ -240,6 +242,10 @@ def _apply(self, evse: SpanEvseSnapshot) -> None: ] if ceilings: self._attr_native_max_value = min(ceilings) + # A charger commissioned below its declared minimum (ceiling 16 A, range + # 20:32) would otherwise report a minimum above its maximum, which Home + # Assistant cannot render; the ceiling is the hardware bound, so it wins. + self._attr_native_min_value = min(self._attr_native_min_value, self._attr_native_max_value) @property def available(self) -> bool: diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py index 7b6c798a..bbbd60c7 100644 --- a/tests/test_evse_charge_limit.py +++ b/tests/test_evse_charge_limit.py @@ -691,25 +691,29 @@ async def test_a_ceiling_below_the_declared_maximum_still_bounds_the_maximum( @pytest.mark.parametrize( - ("range_format", "minimum", "maximum"), - [("6:", 6, None), (":24", 0, 24)], + ("range_format", "declared_minimum", "declared_maximum", "minimum"), + [("6:", 6, None, 6), (":24", None, 24, 0)], ids=["open-maximum", "open-minimum"], ) async def test_an_open_side_falls_back_for_that_side_only( - hass: HomeAssistant, range_format: str, minimum: int, maximum: int | None + hass: HomeAssistant, + range_format: str, + declared_minimum: int | None, + declared_maximum: int | None, + minimum: int, ) -> None: tree = _declared_range(EVSE, range_format) snapshot = schema_one_snapshot(tree) declared = snapshot.evse[_serial(tree, EVSE)] assert (declared.charge_current_limit_minimum_a, declared.charge_current_limit_maximum_a) == ( - (minimum or None), - maximum, + declared_minimum, + declared_maximum, ) ceiling = _published(tree, EVSE, CEILING_TOPIC) entity = _for(await _created(hass, snapshot), tree, EVSE) assert entity.native_min_value == minimum - assert entity.native_max_value == (ceiling if maximum is None else maximum) + assert entity.native_max_value == (ceiling if declared_maximum is None else declared_maximum) async def test_the_range_follows_the_charger_when_it_is_recommissioned(hass: HomeAssistant) -> None: @@ -725,22 +729,47 @@ async def test_the_range_follows_the_charger_when_it_is_recommissioned(hass: Hom assert (entity.native_min_value, entity.native_max_value) == (0, _published(tree, EVSE, CEILING_TOPIC)) -def test_the_range_is_applied_only_where_the_limit_is_settable() -> None: - """Where the limit is not settable the entity keeps its old bounds. +async def test_the_range_is_dropped_when_the_limit_stops_being_settable( + hass: HomeAssistant, +) -> None: + """An entity created while settable then receives a snapshot where it is not. - Such a charger gets no entity from the platform; the description is asked - directly, since an entity that must not exist cannot be asked for its range. + The platform gates creation on `$settable` only at setup; the entity lives + on and `_apply` runs on every later snapshot. The declared range is applied + only where the limit is settable, so the bounds go back to the fallback. """ tree = schema_one_tree() - settable = schema_one_snapshot(tree).evse[_serial(tree, EVSE)] - refused = _not_settable(EVSE).evse[_serial(tree, EVSE)] - assert settable.charge_current_limit_settable is True - assert refused.charge_current_limit_settable is False - - assert EVSE_CHARGE_CURRENT_LIMIT.minimum_fn(settable) == 6 - assert EVSE_CHARGE_CURRENT_LIMIT.declared_maximum_fn(settable) == 32 - assert EVSE_CHARGE_CURRENT_LIMIT.minimum_fn(refused) is None - assert EVSE_CHARGE_CURRENT_LIMIT.declared_maximum_fn(refused) is None + ceiling = _published(tree, EVSE, CEILING_TOPIC) + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + assert (entity.native_min_value, entity.native_max_value) == (6, 32) + + refused = _not_settable(EVSE) + assert refused.evse[_serial(tree, EVSE)].charge_current_limit_settable is False + _refreshed(entity, refused) + + assert (entity.native_min_value, entity.native_max_value) == (0, ceiling) + + +async def test_a_declared_maximum_bounds_the_control_while_the_ceiling_is_unpublished( + hass: HomeAssistant, +) -> None: + """The lower of each bound present: with no ceiling, the declared maximum alone.""" + tree = _declared_range(EVSE, "6:24") + del tree[EVSE][CEILING_TOPIC] + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + + assert entity.native_max_value == 24 + assert entity.available is False + + +async def test_a_declared_minimum_above_the_maximum_is_clamped_to_it(hass: HomeAssistant) -> None: + """A charger commissioned below its declared minimum must not report min > max.""" + tree = _declared_range(EVSE, "20:32") + tree[EVSE][CEILING_TOPIC] = "16" + entity = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE) + + assert entity.native_max_value == 16 + assert entity.native_min_value == 16 # --------------------------------------------------------------------------- From a2439fe84c7403788547edd870062dd8985e80dc Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:28:35 -0700 Subject: [PATCH 7/8] chore: release 2.2.0b5 Pins span-panel-api 3.8.0b2 and span-panel-api-schema-1 1.4.0b4, which carry the masked device-tree export the diagnostics now include, the declared charge-limit range the charge-limit number is bounded by, and the reference tree recaptured with ebus-panel-sim 0.9.0. span-panel-api-schema-0 stays 1.3.0b1. --- custom_components/span_panel/manifest.json | 6 +++--- pyproject.toml | 6 +++--- requirements_test.txt | 4 ++-- uv.lock | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json index 1d757fcf..de3ed2cf 100644 --- a/custom_components/span_panel/manifest.json +++ b/custom_components/span_panel/manifest.json @@ -24,10 +24,10 @@ "quality_scale": "gold", "requirements": [ "span-panel-api-schema-0==1.3.0b1", - "span-panel-api-schema-1==1.4.0b3", - "span-panel-api==3.8.0b1" + "span-panel-api-schema-1==1.4.0b4", + "span-panel-api==3.8.0b2" ], - "version": "2.2.0b4", + "version": "2.2.0b5", "zeroconf": [ { "type": "_span._tcp.local." diff --git a/pyproject.toml b/pyproject.toml index 384cdaaa..b716e30d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span" -version = "2.2.0b4" +version = "2.2.0b5" description = "Span Panel Custom Integration for Home Assistant" authors = [{name = "SpanPanel"}] license = {text = "MIT"} @@ -8,13 +8,13 @@ readme = "README.md" requires-python = ">=3.14.3,<3.15" dependencies = [ "homeassistant==2026.9.4", - "span-panel-api==3.8.0b1", + "span-panel-api==3.8.0b2", # Separate distributions, not dependencies of the bootstrap: adapters are found # through the `span_panel_api.schema_adapters` entry-point group, so each one has # to be installed for discovery to see it. manifest.json carries the same three, # and that is the list Home Assistant actually reads. "span-panel-api-schema-0==1.3.0b1", - "span-panel-api-schema-1==1.4.0b3", + "span-panel-api-schema-1==1.4.0b4", ] [dependency-groups] diff --git a/requirements_test.txt b/requirements_test.txt index 7f5d7500..24551872 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,6 +4,6 @@ setuptools>=65.7.0 pytest-homeassistant-custom-component==0.13.367 ebus-panel-sim==0.10.0b2 homeassistant==2026.9.4 -span-panel-api==3.8.0b1 +span-panel-api==3.8.0b2 span-panel-api-schema-0==1.3.0b1 -span-panel-api-schema-1==1.4.0b3 +span-panel-api-schema-1==1.4.0b4 diff --git a/uv.lock b/uv.lock index 8d23e474..5e6150a8 100644 --- a/uv.lock +++ b/uv.lock @@ -2560,7 +2560,7 @@ wheels = [ [[package]] name = "span" -version = "2.2.0b4" +version = "2.2.0b5" source = { virtual = "." } dependencies = [ { name = "homeassistant" }, @@ -2619,7 +2619,7 @@ dev = [ [[package]] name = "span-panel-api" -version = "3.8.0b1" +version = "3.8.0b2" source = { editable = "../../span/span-panel-api" } dependencies = [ { name = "httpx" }, @@ -2643,7 +2643,7 @@ dev = [ { name = "black" }, { name = "coverage" }, { name = "cryptography", specifier = ">=50.0.0" }, - { name = "ebus-panel-sim", specifier = "==0.8.0" }, + { name = "ebus-panel-sim", specifier = "==0.9.0" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pylint" }, @@ -2672,7 +2672,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap [[package]] name = "span-panel-api-schema-1" -version = "1.4.0b3" +version = "1.4.0b4" source = { editable = "../../span/span-panel-api/packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From e7915025ecbbe6c8d6651bae5a4aa54034c970e8 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:30:09 -0700 Subject: [PATCH 8/8] docs(changelog): shorten the diagnostics device-tree entry --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978fd81e..239cdd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,7 @@ All notable changes to this project will be documented in this file. panel publishes them. - **A battery the panel declares gets its device before its first reading**, reading unknown until then, and a battery fed by a circuit reports its connection. - **Registration says when to retry** if the panel limits attempts, and setup asks only for the passphrase where the panel does not support proximity. -- **Diagnostics include the panel's device tree**: every device and property the panel publishes, with serial numbers, device ids and site details replaced, so a - report can be looked into without a separate capture. +- **Diagnostics include the panel's device tree**, with identifiers and site details masked, so a report needs no separate capture. ### Changed