diff --git a/CHANGELOG.md b/CHANGELOG.md index c94e74dd..239cdd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +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**, with identifiers and site details masked, so a report needs no separate capture. ### Changed @@ -46,6 +47,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/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/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/custom_components/span_panel/number.py b/custom_components/span_panel/number.py index 3b6391e7..4a72829d 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), @@ -201,14 +215,37 @@ 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, 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) - 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) + # 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/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/tests/captures_replay.py b/tests/captures_replay.py index 689c52ea..c0aec775 100644 --- a/tests/captures_replay.py +++ b/tests/captures_replay.py @@ -25,7 +25,14 @@ from typing import Final from ebus_panel_sim import Tree, load_reference_capture -from span_panel_api import ControlInterceptor, LeafNameMismatch, SpanPanelSnapshot +from ebus_sdk import encode_empty_string +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 @@ -177,12 +184,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 +225,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 @@ -249,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_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_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_evse_charge_limit.py b/tests/test_evse_charge_limit.py index b6d38629..bbbd60c7 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,34 @@ 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 _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. 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 +185,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 +329,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 +346,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 +377,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 +395,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 +534,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 +565,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 @@ -515,9 +633,145 @@ 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", "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, + 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) == ( + 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 declared_maximum is None else declared_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)) + + +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. + + 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() + 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 + + # --------------------------------------------------------------------------- # Writing # --------------------------------------------------------------------------- @@ -687,7 +941,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 # --------------------------------------------------------------------------- diff --git a/tests/test_privacy.py b/tests/test_privacy.py index a4e5a973..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", @@ -74,6 +74,53 @@ def _with_site() -> RetainedTree: return tree +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. + +`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: + """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) + + +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. + + 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)] + 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]: panel = tree[panel_device_id(tree)] values = [panel[path] for path in SITE_READINGS] @@ -105,7 +152,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 ) @@ -116,8 +165,11 @@ 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 (*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: @@ -189,7 +241,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 ) @@ -200,5 +254,8 @@ 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 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_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. 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, 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" },